From 7c9734d9187d453b4658f9cc8ed7ca801c5def58 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Fri, 5 Jun 2026 19:06:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E8=BF=9C=E7=A8=8B=E9=81=A5?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/build.gradle.kts | 14 +- android/gradle.properties | 13 +- lib/components/tcp_status_indicator.dart | 123 ++ lib/core/app/app_user_cubit.dart | 2 + lib/core/consts/tcp_consts.dart | 2 +- lib/core/di/injection.dart | 19 +- lib/core/network/tcp/tcp_client.dart | 578 ++++++--- lib/core/network/tcp/tcp_status_cubit.dart | 52 + .../impl/auth_tcp_datasource_impl.dart | 84 +- .../auth/presentation/bloc/auth_cubit.dart | 95 +- .../auth/presentation/bloc/login_cubit.dart | 12 +- .../impl/device_http_datasource_impl.dart | 2 +- .../models/device_add_path_point_model.dart | 12 +- .../models/device_work_area_param_model.dart | 24 +- .../entities/running_status_entity.dart | 10 +- .../presentation/bloc/device_status_bloc.dart | 10 +- .../presentation/widgets/ImmersionHeader.dart | 184 ++- .../widgets/map/testmap_pages.dart | 961 ++++++++++++--- .../pages/custom_main_container.dart | 37 +- .../datasources/remote_http_datasource.dart | 30 +- .../datasources/remote_tcp_datasource.dart | 146 ++- .../remote_control_repository_impl.dart | 116 +- .../remote_control_repository.dart | 8 +- .../usecase/remote_control_usecase.dart | 32 +- .../request_control_permission_usecase.dart | 31 + .../presentation/bloc/control_mode.dart | 1 + .../bloc/remote_control_cubit.dart | 653 +++++++--- .../bloc/remote_control_state.dart | 27 +- .../data/models/running_status_model.dart | 27 + .../pages/remote_control_page.dart | 383 +++++- .../presentation/widgets/top_status_bar.dart | 356 +++--- .../widgets/webrtc/webrtc_local_player.dart | 17 +- .../datasources/drone_station_datasource.dart | 3 +- .../drone_station_datasource_impl.dart | 3 +- .../drone_station_repository_impl.dart | 3 +- .../domain/entities/drone_station_entity.dart | 32 +- .../domain/entities/video_stream_entity.dart | 37 +- .../drone_station_repository.dart | 3 +- .../usecases/get_video_stream_usecase.dart | 4 +- .../presentation/bloc/drone_station_bloc.dart | 2 +- .../bloc/drone_station_state.dart | 8 +- .../pages/device_status_page.dart | 161 ++- .../pages/drone_monitor_page.dart | 1073 ++++++++++++----- .../pages/drone_station_detail_page.dart | 406 ++++++- .../pages/robot_control_page.dart | 5 +- .../presentation/pages/robot_list_page.dart | 14 +- .../widgets/robot_control_panel.dart | 233 ++-- .../datasources/site_datasource_impl.dart | 30 +- .../home/presentation/pages/home_v2_page.dart | 13 +- .../widgets/tcp_status_indicator.dart | 109 ++ lib/main.dart | 58 +- pubspec.lock | 32 + pubspec.yaml | 5 +- 53 files changed, 4683 insertions(+), 1612 deletions(-) create mode 100644 lib/components/tcp_status_indicator.dart create mode 100644 lib/core/network/tcp/tcp_status_cubit.dart create mode 100644 lib/features/remote_control/domain/usecase/request_control_permission_usecase.dart create mode 100644 lib/features/remote_control/presentation/bloc/control_mode.dart create mode 100644 lib/features/remote_control/presentation/data/models/running_status_model.dart create mode 100644 lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 0236d04c..fb28f348 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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/") } } } diff --git a/android/gradle.properties b/android/gradle.properties index 224d0d8c..0cf520d3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 \ No newline at end of file +systemProp.http.ssl.ignore.validity.dates=true + +# Android SDK 镜像配置(使用清华大学镜像) +systemProp.android.sdkmanager.channel=stable \ No newline at end of file diff --git a/lib/components/tcp_status_indicator.dart b/lib/components/tcp_status_indicator.dart new file mode 100644 index 00000000..c1a2fcc3 --- /dev/null +++ b/lib/components/tcp_status_indicator.dart @@ -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 createState() => _TcpStatusIndicatorState(); +} + +class _TcpStatusIndicatorState extends State + with SingleTickerProviderStateMixin { + late TcpStatusCubit _tcpStatusCubit; + bool _hasActivity = false; + AnimationController? _controller; + Animation? _animation; + + @override + void initState() { + super.initState(); + _tcpStatusCubit = GetIt.I(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + + _animation = Tween(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, + ), + ] + : [], + ), + ); + }, + ), + ); + } +} diff --git a/lib/core/app/app_user_cubit.dart b/lib/core/app/app_user_cubit.dart index 1addcc9a..bfe1c68f 100644 --- a/lib/core/app/app_user_cubit.dart +++ b/lib/core/app/app_user_cubit.dart @@ -7,7 +7,9 @@ class AppUserCubit extends Cubit { AppUserCubit() : super(const AppUserState()); void setAuth(UserEntity user) { + print('✅ [AppUserCubit] setAuth 被调用,用户: ${user.username}'); emit(state.copyWith(user)); + print('✅ [AppUserCubit] 状态已更新,当前用户: ${state.user?.username}'); } void clearAuth() { diff --git a/lib/core/consts/tcp_consts.dart b/lib/core/consts/tcp_consts.dart index 51eb14e2..071a6471 100644 --- a/lib/core/consts/tcp_consts.dart +++ b/lib/core/consts/tcp_consts.dart @@ -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; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 6c4d03a2..c4d70eee 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -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 init() async { sl.registerLazySingleton(() => GetHomeDataUseCase(sl())); // Site (场站) - sl.registerLazySingleton(() => SiteDataSourceImpl(sl())); + sl.registerLazySingleton( + () => SiteDataSourceImpl(sl(), sl(), sl()), + ); sl.registerLazySingleton(() => SiteRepositoryImpl(sl())); sl.registerLazySingleton(() => GetSiteListUseCase(sl())); @@ -301,9 +305,7 @@ Future init() async { ); /// Robot List V2 - sl.registerFactory( - () => RobotListBloc(sl()), - ); + sl.registerFactory(() => RobotListBloc(sl())); /// Alarm Center V2 sl.registerLazySingleton( @@ -333,9 +335,7 @@ Future init() async { sl.registerLazySingleton( () => ConfirmAlarmUseCase(sl()), ); - sl.registerLazySingleton( - () => AIDiagnosisUseCase(sl()), - ); + sl.registerLazySingleton(() => AIDiagnosisUseCase(sl())); sl.registerFactory( () => AlarmDetailCubit( getAlarmDetailUseCase: sl(), @@ -347,6 +347,9 @@ Future init() async { /// 5. 状态管理 (Cubit/Bloc) sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册 + // TCP状态管理 Cubit + sl.registerLazySingleton(() => TcpStatusCubit()); + // 🔥 SiteCubit (全局共享,持久化选中场站) sl.registerLazySingleton(() => SiteCubit(sl())); @@ -386,7 +389,7 @@ Future init() async { ); // 🔥 RemoteControlCubit 注入 DeviceStatusBloc(工厂模式,每次新建) - sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl())); + sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl(), sl())); // 🔥 PermissionRequestBloc 用于首页权限弹窗(单例,通过 NetMessageDispatcher 监听) sl.registerLazySingleton( diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index 8ae0abbb..8cd811e1 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -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 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(); - // 新增:心跳定时器 Timer? _heartbeatTimer; Timer? _reconnectTimer; // 重连定时器 @@ -60,12 +63,64 @@ class TcpClient { _logger.logWithLevel('🔌 [TCP] 物理断开 Socket...', shouldLog: true); _socket!.destroy(); // 或者 .close() _socket = null; + + // 更新连接状态为未连接 + try { + GetIt.I().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().setDisconnected(); + } catch (e) { + _logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false); + } + + _logger.logWithLevel('✅ [TCP] 已彻底断开,下次登录需重新初始化', shouldLog: true); + } + // 通过参数配置,不硬编码 Future connect({required String host, required int port}) async { //debugPrint('🔌 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条 _logger.logWithLevel('🔌 [TCP] 开始连接:$host:$port', shouldLog: true); + + // 更新连接状态为连接中 + try { + GetIt.I().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) { - // 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}',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 (packet.command == 0xFF) { - //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 看是否有错误信息 - } + // 更新连接状态为已连接 + try { + GetIt.I().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 + // } + // } + // _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 (packet.command == 0xFF) { + //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 看是否有错误信息 + } + } + } 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 _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 data) { if (_socket == null) throw Exception("Socket not connected"); _socket!.write(jsonEncode(data)); } - void sendnew(List data){ + void sendnew(List data) { _socket!.add(data); } - void sendRaw(int cmd, List 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 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().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().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().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 _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) - await eitherResult.fold( - (failure) { - // 处理失败:打印日志或抛出异常 - // debugPrint('❌ [AuthTcp] 获取设备列表失败:$failure'); - _logger.logWithLevel('❌ [AuthTcp] 获取设备列表失败:$failure'); - // _sendAuthPacket(); - throw Exception('获取设备列表失败:$failure'); - }, - (devices) async { - // 处理成功:devices 现在是真正的 List - 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) + 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 + 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 connectBySwitch({required String host, required int port, required String deviceName}) async { - isUserSwitch=true; - // debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条 + Future 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 _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); - } } diff --git a/lib/core/network/tcp/tcp_status_cubit.dart b/lib/core/network/tcp/tcp_status_cubit.dart new file mode 100644 index 00000000..bc714d06 --- /dev/null +++ b/lib/core/network/tcp/tcp_status_cubit.dart @@ -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 { + 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, + )); + } +} diff --git a/lib/features/auth/data/datasources/impl/auth_tcp_datasource_impl.dart b/lib/features/auth/data/datasources/impl/auth_tcp_datasource_impl.dart index 173de890..2346ac61 100644 --- a/lib/features/auth/data/datasources/impl/auth_tcp_datasource_impl.dart +++ b/lib/features/auth/data/datasources/impl/auth_tcp_datasource_impl.dart @@ -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) - await eitherResult.fold( - (failure) { - // 处理失败:打印日志或抛出异常 - debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure'); - throw Exception('获取设备列表失败:$failure'); - }, - (devices) async { - // 处理成功:devices 现在是真正的 List - 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) + // await eitherResult.fold( + // (failure) { + // // 处理失败:打印日志或抛出异常 + // debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure'); + // throw Exception('获取设备列表失败:$failure'); + // }, + // (devices) async { + // // 处理成功:devices 现在是真正的 List + // 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; + // } } } diff --git a/lib/features/auth/presentation/bloc/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit.dart index b62c10c6..b1bee062 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit.dart @@ -38,7 +38,13 @@ class AuthCubit extends Cubit { 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 { 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 { /// 当 HTTP 登录/注册成功后调用 Future 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)); } @@ -87,12 +93,12 @@ class AuthCubit extends Cubit { /// 退出登录 (主动或被动) Future logout() async { //print("退出登录"); - + // 🔥 关键修复:在断开TCP前,先清空所有业务状态,避免重连触发旧数据 _clearAllBusinessState(); - + await storage.deleteUser(); - tcp.disconnect(); + tcp.disconnect(); // 断开后会自动重连 appCubit.clearAuth(); emit(AuthUnauthenticated()); } @@ -103,22 +109,22 @@ class AuthCubit extends Cubit { final devicesCubit = GetIt.I(); final deviceStatusBloc = GetIt.I(); final siteCubit = GetIt.I(); - + // 1. 清空设备列表和选中设备 devicesCubit.emit(const DevicesState()); debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态'); _logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态'); - + // 2. 清空设备实时状态 deviceStatusBloc.add(DeviceStatusReset()); debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态'); _logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态'); - + // 3. 清空全局选中的场站 siteCubit.clearSelectedSite(); debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态'); _logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态'); - + debugPrint('✅ [AUTH] 所有业务状态已清空'); _logger.logWithLevel('✅ [AUTH] 所有业务状态已清空'); } catch (e) { @@ -129,65 +135,78 @@ class AuthCubit extends Cubit { /// 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 close() { _kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听 return super.close(); } - /// 🔥 息屏/后台后恢复到前台时的重连方法 Future 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(); } diff --git a/lib/features/auth/presentation/bloc/login_cubit.dart b/lib/features/auth/presentation/bloc/login_cubit.dart index 07571308..44ccd8e2 100644 --- a/lib/features/auth/presentation/bloc/login_cubit.dart +++ b/lib/features/auth/presentation/bloc/login_cubit.dart @@ -20,15 +20,23 @@ class LoginCubit extends Cubit { Future 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().state.user; // final devicesCubit = sl(); // 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())); diff --git a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart index a141c4c0..9d12f252 100644 --- a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart +++ b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart @@ -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'] ?? '业务异常'); } diff --git a/lib/features/devices/data/models/device_add_path_point_model.dart b/lib/features/devices/data/models/device_add_path_point_model.dart index 30ecbc50..382df623 100644 --- a/lib/features/devices/data/models/device_add_path_point_model.dart +++ b/lib/features/devices/data/models/device_add_path_point_model.dart @@ -4,15 +4,21 @@ class DeviceAddPathPointModel { DeviceAddPathPointModel({required this.latitude, required this.longitude}); - Map toJson() => {'lat': latitude.toString(), 'lon': longitude.toString()}; + Map toJson() => { + 'lat': latitude.toString(), + 'lng': longitude.toString(), + }; factory DeviceAddPathPointModel.fromJson(Map 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()}}'; } diff --git a/lib/features/devices/data/models/device_work_area_param_model.dart b/lib/features/devices/data/models/device_work_area_param_model.dart index 202dbb9b..be0285d7 100644 --- a/lib/features/devices/data/models/device_work_area_param_model.dart +++ b/lib/features/devices/data/models/device_work_area_param_model.dart @@ -4,7 +4,7 @@ class ReferencePoint { ReferencePoint({required this.lat, required this.lon}); - Map toJson() => {'lat': lat, 'lon': lon}; + Map toJson() => {'lat': lat, 'lng': lon}; } class OuterBoundary { @@ -13,7 +13,10 @@ class OuterBoundary { OuterBoundary({required this.position, required this.sideWidth}); - Map toJson() => {'position': position.map((p) => p.toJson()).toList(), 'sideWidth': sideWidth}; + Map 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 或 List> 类型'); + throw ArgumentError( + 'position 必须是 List 或 List> 类型', + ); } } // 核心修改:toJson 输出二维数组格式 Map 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 final List> position = outerList.map((innerList) { // 3. 处理内层数组,转为 Position 对象列表 - return (innerList as List).map((p) => Position.fromJson(p as Map)).toList(); + return (innerList as List) + .map((p) => Position.fromJson(p as Map)) + .toList(); }).toList(); // 4. 提取sideWidth(确保数字类型) @@ -72,9 +81,10 @@ class Position { factory Position.fromJson(Map 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 toJson() => {'lat': lat, 'lon': lon}; + Map toJson() => {'lat': lat, 'lng': lon}; } diff --git a/lib/features/devices/domain/entities/running_status_entity.dart b/lib/features/devices/domain/entities/running_status_entity.dart index cc93eda7..e2693385 100644 --- a/lib/features/devices/domain/entities/running_status_entity.dart +++ b/lib/features/devices/domain/entities/running_status_entity.dart @@ -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 个字段,不足的用默认值填充 diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index b9f8e212..ff47be1c 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -73,10 +73,10 @@ class DeviceStatusBloc extends Bloc { .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 { 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 { 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')); } diff --git a/lib/features/home/presentation/widgets/ImmersionHeader.dart b/lib/features/home/presentation/widgets/ImmersionHeader.dart index a0c00918..47caedbc 100644 --- a/lib/features/home/presentation/widgets/ImmersionHeader.dart +++ b/lib/features/home/presentation/widgets/ImmersionHeader.dart @@ -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().state.user?.username ?? ""; + final username = + context.read().state.user?.username ?? + ""; context.read().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().switchDevice(device); + await context.read().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"); }, diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index 009efa89..d8e0f260 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -22,8 +22,10 @@ import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart'; import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; -import 'package:maibu_satabot_v2/features/devices/data/models/device_add_path_point_model.dart' as work_area_model; -import 'package:maibu_satabot_v2/features/devices/data/models/device_work_area_param_model.dart' as work_area_model; +import 'package:maibu_satabot_v2/features/devices/data/models/device_add_path_point_model.dart' + as work_area_model; +import 'package:maibu_satabot_v2/features/devices/data/models/device_work_area_param_model.dart' + as work_area_model; import 'package:maibu_satabot_v2/features/devices/data/repositories/generate_path_repository_Impl.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_work_record_usecase.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/select_work_record_usecase.dart'; @@ -63,7 +65,8 @@ const String kSavedIsWorkPanelOpen = 'saved_is_work_panel_open'; const String kSavedSelectedPlot = 'saved_selected_plot'; const String kSavedStartWorkList = 'saved_start_work_list'; const String kSavedWorkMode = 'saved_work_mode'; -const String kSavedWorkStatus = 'saved_work_status'; // 作业状态(idle/working/paused) +const String kSavedWorkStatus = + 'saved_work_status'; // 作业状态(idle/working/paused) const String kSavedIsStartWork = 'saved_is_start_work'; // 是否开始作业 const String kSavedIsStopWork = 'saved_is_stop_work'; // 是否点击过停止 const String kSavedTPMode = "saved_tp_mode"; @@ -78,7 +81,12 @@ class PlotData { final String imageUrl; // 图片URL(本地/assets/网络都可) final String? jsonData; // 原始数据的JSON字符串(可选,便于调试或后续使用) - PlotData({required this.id, required this.plotName, required this.imageUrl, this.jsonData}); + PlotData({ + required this.id, + required this.plotName, + required this.imageUrl, + this.jsonData, + }); } class PlotDataPath { @@ -149,7 +157,8 @@ class _MapPageEnterpriseState extends State { LatLng _mapCenter = const LatLng(39.9042, 116.4074); double _headingAngle = 0.0; // 当前机器航向角(单位:度) - List typedPathList = []; // 存储生成路径的坐标列表(已转换为LatLng) + List typedPathList = + []; // 存储生成路径的坐标列表(已转换为LatLng) // ========== 障碍物模式核心状态 ========== List> _obstacleHoles = []; // 存储多组障碍物打点(嵌套数组:每组是一个障碍物) @@ -194,7 +203,10 @@ class _MapPageEnterpriseState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mapController.camera != null) { final originalCenter = _mapController.camera!.center; - final highPrecisionCenter = LatLng(originalCenter.latitude, originalCenter.longitude); + final highPrecisionCenter = LatLng( + originalCenter.latitude, + originalCenter.longitude, + ); setState(() { _mapCenter = highPrecisionCenter; @@ -216,7 +228,10 @@ class _MapPageEnterpriseState extends State { if (event is MapEventMove || event is MapEventMoveEnd) { if (mounted && _mapController.camera != null) { final originalCenter = _mapController.camera!.center; - final highPrecisionCenter = LatLng(originalCenter.latitude, originalCenter.longitude); + final highPrecisionCenter = LatLng( + originalCenter.latitude, + originalCenter.longitude, + ); setState(() { _mapCenter = highPrecisionCenter; @@ -280,16 +295,24 @@ class _MapPageEnterpriseState extends State { final prefs = await SharedPreferences.getInstance(); final workStatusStr = prefs.getString(kSavedWorkStatus); if (workStatusStr != null) { - _workStatus = WorkStatus.values.firstWhere((e) => e.toString() == workStatusStr, orElse: () => WorkStatus.idle); + _workStatus = WorkStatus.values.firstWhere( + (e) => e.toString() == workStatusStr, + orElse: () => WorkStatus.idle, + ); } final modeStr = prefs.getString(kSavedTPMode); if (modeStr != null) { - _traceManager.setMode(TPMode.values.firstWhere((e) => e.toString() == modeStr)); + _traceManager.setMode( + TPMode.values.firstWhere((e) => e.toString() == modeStr), + ); } final robotModeStr = prefs.getString(kSavedCurrentRobotMode); if (robotModeStr != null) { - _currentRobotMode = RobotMode.values.firstWhere((e) => e.toString() == robotModeStr, orElse: () => RobotMode.point); + _currentRobotMode = RobotMode.values.firstWhere( + (e) => e.toString() == robotModeStr, + orElse: () => RobotMode.point, + ); } // 读取 生成完成的路径 typedPathList @@ -298,7 +321,10 @@ class _MapPageEnterpriseState extends State { final List typedPathJson = jsonDecode(typedPathString); setState(() { typedPathList = typedPathJson.map((p) { - return work_area_model.DeviceAddPathPointModel(latitude: p['lat'], longitude: p['lng']); + return work_area_model.DeviceAddPathPointModel( + latitude: p['lat'], + longitude: p['lng'], + ); }).toList(); }); } @@ -315,21 +341,42 @@ class _MapPageEnterpriseState extends State { final pathJson = prefs.getString(kSavedGcjPathPoints); if (pathJson != null) { final List pathList = jsonDecode(pathJson); - gcjPathPoints = pathList.map((item) => LatLng(double.parse(item['lat'].toString()), double.parse(item['lng'].toString()))).toList(); + gcjPathPoints = pathList + .map( + (item) => LatLng( + double.parse(item['lat'].toString()), + double.parse(item['lng'].toString()), + ), + ) + .toList(); } // 加载外边界点 final outerJson = prefs.getString(kSavedGcjOuterPoints); if (outerJson != null) { final List outerList = jsonDecode(outerJson); - gcjOuterPoints = outerList.map((item) => LatLng(double.parse(item['lat'].toString()), double.parse(item['lng'].toString()))).toList(); + gcjOuterPoints = outerList + .map( + (item) => LatLng( + double.parse(item['lat'].toString()), + double.parse(item['lng'].toString()), + ), + ) + .toList(); } // 加载打点数据 final markedJson = prefs.getString(kSavedMarkedPoints); if (markedJson != null) { final List markedList = jsonDecode(markedJson); - _markedPoints = markedList.map((item) => LatLng(double.parse(item['lat'].toString()), double.parse(item['lng'].toString()))).toList(); + _markedPoints = markedList + .map( + (item) => LatLng( + double.parse(item['lat'].toString()), + double.parse(item['lng'].toString()), + ), + ) + .toList(); } // 加载障碍物数据 @@ -337,7 +384,14 @@ class _MapPageEnterpriseState extends State { if (obstacleJson != null) { final List obstacleList = jsonDecode(obstacleJson); _obstacleHoles = obstacleList.map((hole) { - return (hole as List).map((item) => LatLng(double.parse(item['lat'].toString()), double.parse(item['lng'].toString()))).toList(); + return (hole as List) + .map( + (item) => LatLng( + double.parse(item['lat'].toString()), + double.parse(item['lng'].toString()), + ), + ) + .toList(); }).toList(); } @@ -352,7 +406,10 @@ class _MapPageEnterpriseState extends State { workMode = prefs.getString(kSavedWorkMode) ?? "弓字模式"; // 读取 _currentWorkMode = prefs.getString(kSavedCurrentWorkMode) != null - ? WorkMode.values.firstWhere((e) => e.toString() == prefs.getString(kSavedCurrentWorkMode), orElse: () => WorkMode.bow) + ? WorkMode.values.firstWhere( + (e) => e.toString() == prefs.getString(kSavedCurrentWorkMode), + orElse: () => WorkMode.bow, + ) : WorkMode.bow; // 3. 恢复作业列表 final startWorkJson = prefs.getString(kSavedStartWorkList); @@ -406,24 +463,34 @@ class _MapPageEnterpriseState extends State { final prefs = await SharedPreferences.getInstance(); // 保存路径点 - final pathList = gcjPathPoints.map((point) => {'lat': point.latitude, 'lng': point.longitude}).toList(); + final pathList = gcjPathPoints + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); prefs.setString(kSavedGcjPathPoints, jsonEncode(pathList)); // 保存外边界点 - final outerList = gcjOuterPoints.map((point) => {'lat': point.latitude, 'lng': point.longitude}).toList(); + final outerList = gcjOuterPoints + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); prefs.setString(kSavedGcjOuterPoints, jsonEncode(outerList)); // 保存打点数据 - final markedList = _markedPoints.map((point) => {'lat': point.latitude, 'lng': point.longitude}).toList(); + final markedList = _markedPoints + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); prefs.setString(kSavedMarkedPoints, jsonEncode(markedList)); // 保存障碍物数据 final obstacleList = _obstacleHoles.map((hole) { - return hole.map((point) => {'lat': point.latitude, 'lng': point.longitude}).toList(); + return hole + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); }).toList(); prefs.setString(kSavedObstacleHoles, jsonEncode(obstacleList)); - final typedPath = typedPathList.map((point) => {'lat': point.latitude, 'lng': point.longitude}).toList(); + final typedPath = typedPathList + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); prefs.setString("kSavedTypedPathList", jsonEncode(typedPath)); // 保存作业区域完成状态 @@ -515,10 +582,21 @@ class _MapPageEnterpriseState extends State { } bool _isValidLatLng(double lat, double lng) { - return lat != 0 && lng != 0 && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; + return lat != 0 && + lng != 0 && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180; } - void _onDeviceData(double lng, double lat, bool obfFlag, int headingStatus, dynamic controlMode) { + void _onDeviceData( + double lng, + double lat, + bool obfFlag, + int headingStatus, + dynamic controlMode, + ) { try { // 1. 校验坐标是否有效 bool isValid = _isValidLatLng(lat, lng); @@ -617,7 +695,14 @@ class _MapPageEnterpriseState extends State { context: context, barrierDismissible: false, // 禁止点击外部关闭 builder: (ctx) => const Center( - child: SizedBox(width: 60, height: 60, child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white)), + child: SizedBox( + width: 60, + height: 60, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + ), + ), ), ); @@ -631,7 +716,13 @@ class _MapPageEnterpriseState extends State { } else { // 截图失败时,允许空图片保存(可根据业务调整为强制失败) _savePlotData(plotName, null); - _showPageToast(message: loc.translate('route_planning.save_success') + "," + "但地图截图生成失败!", type: ToastType.warn); + _showPageToast( + message: + loc.translate('route_planning.save_success') + + "," + + "但地图截图生成失败!", + type: ToastType.warn, + ); //ToastUtils.showWarn(context, '地块保存成功,但地图截图生成失败!'); //ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('地块保存成功,但地图截图生成失败!'), backgroundColor: Colors.amber)); @@ -670,7 +761,9 @@ class _MapPageEnterpriseState extends State { // 格式化 JSON 打印,更易读 try { dynamic jsonObj = jsonDecode(content); - debugPrint(' 内容 (content): ${const JsonEncoder.withIndent(' ').convert(jsonObj)}'); + debugPrint( + ' 内容 (content): ${const JsonEncoder.withIndent(' ').convert(jsonObj)}', + ); } catch (e) { debugPrint(' 内容 (content): $content'); } @@ -678,7 +771,9 @@ class _MapPageEnterpriseState extends State { } else { // 对于图片文件,打印大小即可,避免打印海量 Base64 file.finalize().first.then((bytes) { - debugPrint(' 文件大小: ${bytes.length} 字节 (约 ${(bytes.length / 1024).toStringAsFixed(1)} KB)'); + debugPrint( + ' 文件大小: ${bytes.length} 字节 (约 ${(bytes.length / 1024).toStringAsFixed(1)} KB)', + ); }); } } @@ -689,7 +784,10 @@ class _MapPageEnterpriseState extends State { // 1. 获取用户ID final userId = context.read().state.user?.userId ?? ""; if (userId.isEmpty) { - _showPageToast(message: loc.translate('route_planning.user_id_empty'), type: ToastType.error); + _showPageToast( + message: loc.translate('route_planning.user_id_empty'), + type: ToastType.error, + ); //ToastUtils.showError(context, '用户ID为空,无法保存!'); return; @@ -703,20 +801,27 @@ class _MapPageEnterpriseState extends State { 'name': plotName, // 地块名称 'outer': // 核心修改:根据机器人模式选择不同的坐标源 - (_currentRobotMode == RobotMode.robot ? _robotModeWgsPoints : gcjOuterPoints).map((point) { - final wgs84 = _currentRobotMode == RobotMode.robot - ? point // Robot模式直接使用原始WGS84坐标 - : gcj02ToWgs84(point.latitude, point.longitude); // Point模式转换 + (_currentRobotMode == RobotMode.robot + ? _robotModeWgsPoints + : gcjOuterPoints) + .map((point) { + final wgs84 = _currentRobotMode == RobotMode.robot + ? point // Robot模式直接使用原始WGS84坐标 + : gcj02ToWgs84( + point.latitude, + point.longitude, + ); // Point模式转换 - return {'lng': wgs84.longitude, 'lat': wgs84.latitude}; - }).toList(), + return {'lng': wgs84.longitude, 'lat': wgs84.latitude}; + }) + .toList(), 'path': _currentWorkMode == WorkMode.custom ? [] // 自定义模式 path 为空数组 : typedPathList .map( (point) => { // 路径点数组 - 'lon': point.longitude, + 'lng': point.longitude, 'lat': point.latitude, }, ) @@ -732,7 +837,10 @@ class _MapPageEnterpriseState extends State { 'jsonData': jsonEncode(savePath), // 将 savePath 转为 JSON 字符串 }; final String workRecordJson = jsonEncode(workRecord); - final http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse('https://serviceri.satabot.com/iot/workRecord/add')); + final http.MultipartRequest request = http.MultipartRequest( + 'POST', + Uri.parse('https://serviceri.satabot.com/iot/workRecord/add'), + ); // 5. 处理文件:将 Base64 转为 MultipartFile (对应 JS 的 dataURLtoFile) if (imgBase64 != null && imgBase64.isNotEmpty) { @@ -744,7 +852,14 @@ class _MapPageEnterpriseState extends State { Uint8List bytes = base64Decode(base64String); // 添加文件 (对应 formData.append("file", file)) - request.files.add(http.MultipartFile.fromBytes('file', bytes, filename: 'image.jpg', contentType: http.MediaType('image', 'jpeg'))); + request.files.add( + http.MultipartFile.fromBytes( + 'file', + bytes, + filename: 'image.jpg', + contentType: http.MediaType('image', 'jpeg'), + ), + ); } // 6. 添加 workRecord (对应 formData.append("workRecord", Blob)) @@ -759,7 +874,10 @@ class _MapPageEnterpriseState extends State { // 7. 发送请求 try { - _showPageToast(message: loc.translate('route_planning.saving') + "「$plotName」...", type: ToastType.loading); + _showPageToast( + message: loc.translate('route_planning.saving') + "「$plotName」...", + type: ToastType.loading, + ); //ToastUtils.showLoading(context, '正在保存地块「$plotName」...'); //ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('正在保存地块...'), backgroundColor: Colors.blue)); @@ -768,7 +886,10 @@ class _MapPageEnterpriseState extends State { final String responseBody = await response.stream.bytesToString(); if (response.statusCode == 200) { - _showPageToast(message: loc.translate('route_planning.save_success') + "「$plotName」", type: ToastType.success); + _showPageToast( + message: loc.translate('route_planning.save_success') + "「$plotName」", + type: ToastType.success, + ); //ToastUtils.showSuccess(context, '地块「$plotName」保存成功!'); _clearLocalData(); @@ -790,7 +911,11 @@ class _MapPageEnterpriseState extends State { } } catch (e) { debugPrint('保存失败: $e'); - _showPageToast(message: loc.translate('route_planning.save_failed') + ": ${e.toString()}", type: ToastType.error); + _showPageToast( + message: + loc.translate('route_planning.save_failed') + ": ${e.toString()}", + type: ToastType.error, + ); //ToastUtils.showError(context, '保存失败: ${e.toString()}'); //ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}'), backgroundColor: Colors.red)); @@ -823,10 +948,16 @@ class _MapPageEnterpriseState extends State { if (_isWorkAreaCompleted && _markedPoints.length >= 3) { _generatePath(showTips: false); } - _showPageToast(message: "已撤回作业区域最后一个点,剩余${_markedPoints.length}个点", type: ToastType.info); + _showPageToast( + message: "已撤回作业区域最后一个点,剩余${_markedPoints.length}个点", + type: ToastType.info, + ); } else { final loc = AppLocalizations.of(context); - _showPageToast(message: loc.translate('route_planning.no_work_points'), type: ToastType.info); + _showPageToast( + message: loc.translate('route_planning.no_work_points'), + type: ToastType.info, + ); } return; } @@ -845,7 +976,10 @@ class _MapPageEnterpriseState extends State { if (_currentObstaclePoints.isEmpty) { _isObstacleEditing = false; } - _showPageToast(message: "已撤回当前空洞最后一个点,剩余${_currentObstaclePoints.length}个点", type: ToastType.info); + _showPageToast( + message: "已撤回当前空洞最后一个点,剩余${_currentObstaclePoints.length}个点", + type: ToastType.info, + ); return; } @@ -856,7 +990,10 @@ class _MapPageEnterpriseState extends State { if (_isWorkAreaCompleted) { _generatePath(showTips: false); } - _showPageToast(message: "已撤回上一个完整空洞,剩余${_obstacleHoles.length}个空洞", type: ToastType.info); + _showPageToast( + message: "已撤回上一个完整空洞,剩余${_obstacleHoles.length}个空洞", + type: ToastType.info, + ); return; } @@ -889,7 +1026,10 @@ class _MapPageEnterpriseState extends State { _saveBoxOpen = false; typedPathList.clear(); final loc = AppLocalizations.of(context); - _showPageToast(message: loc.translate('route_planning.clear_work_area'), type: ToastType.success); + _showPageToast( + message: loc.translate('route_planning.clear_work_area'), + type: ToastType.success, + ); //ToastUtils.showSuccess(context, '已清空所有作业区域点和路径'); //ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有作业区域点和路径'))); @@ -898,7 +1038,10 @@ class _MapPageEnterpriseState extends State { else if (_currentAreaMode == AreaMode.obstacle) { if (_obstacleHoles.isEmpty && _currentObstaclePoints.isEmpty) { final loc = AppLocalizations.of(context); - _showPageToast(message: loc.translate('route_planning.no_obstacles'), type: ToastType.info); + _showPageToast( + message: loc.translate('route_planning.no_obstacles'), + type: ToastType.info, + ); //ToastUtils.showInfo(context, '暂无障碍物可删除'); return; @@ -912,7 +1055,10 @@ class _MapPageEnterpriseState extends State { _generatePath(showTips: false); } final loc = AppLocalizations.of(context); - _showPageToast(message: loc.translate('route_planning.clear_obstacles'), type: ToastType.success); + _showPageToast( + message: loc.translate('route_planning.clear_obstacles'), + type: ToastType.success, + ); //ToastUtils.showSuccess(context, '已清空所有障碍物'); } }); @@ -944,7 +1090,8 @@ class _MapPageEnterpriseState extends State { }); try { - if (_currentLatLng != null && _isValidLatLng(_currentLatLng!.latitude, _currentLatLng!.longitude)) { + if (_currentLatLng != null && + _isValidLatLng(_currentLatLng!.latitude, _currentLatLng!.longitude)) { _mapController.move(_currentLatLng!, 18); } @@ -1039,11 +1186,20 @@ class _MapPageEnterpriseState extends State { await _saveDataToLocal(); if (mounted) { - _showPageToast(message: AppLocalizations.of(context).translate('route_planning.clear_cache_success'), type: ToastType.success); + _showPageToast( + message: AppLocalizations.of( + context, + ).translate('route_planning.clear_cache_success'), + type: ToastType.success, + ); } } catch (e) { if (mounted) { - _showPageToast(message: "${AppLocalizations.of(context).translate('route_planning.refresh_failed')}:${e.toString().substring(0, 50)}", type: ToastType.error); + _showPageToast( + message: + "${AppLocalizations.of(context).translate('route_planning.refresh_failed')}:${e.toString().substring(0, 50)}", + type: ToastType.error, + ); } } finally { if (mounted) { @@ -1070,8 +1226,11 @@ class _MapPageEnterpriseState extends State { setState(() { // 保存当前障碍物组(Robot模式下直接用WGS84坐标) - if (_currentRobotMode == RobotMode.robot && _robotModeObsWgsPoints.isNotEmpty) { - final gcjPoints = _robotModeObsWgsPoints.map((wgs) => wgs84ToGcj02(wgs.latitude, wgs.longitude)).toList(); + if (_currentRobotMode == RobotMode.robot && + _robotModeObsWgsPoints.isNotEmpty) { + final gcjPoints = _robotModeObsWgsPoints + .map((wgs) => wgs84ToGcj02(wgs.latitude, wgs.longitude)) + .toList(); _obstacleHoles.add(gcjPoints); _obstacleWgsHoles.add(List.from(_robotModeObsWgsPoints)); } else { @@ -1081,7 +1240,10 @@ class _MapPageEnterpriseState extends State { _isObstacleEditing = false; // 退出编辑状态 }); _saveDataToLocal(); - _showPageToast(message: "已添加第${_obstacleHoles.length}个障碍物区域", type: ToastType.success); + _showPageToast( + message: "已添加第${_obstacleHoles.length}个障碍物区域", + type: ToastType.success, + ); //ToastUtils.showSuccess(context, '已添加第${_obstacleHoles.length}个障碍物区域'); } @@ -1097,7 +1259,9 @@ class _MapPageEnterpriseState extends State { if (_currentRobotMode == RobotMode.point) { if (_currentAreaMode == AreaMode.work) { _markedPoints.add(_mapCenter); - debugPrint('新增作业区域打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}'); + debugPrint( + '新增作业区域打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}', + ); } else { if (_markedPoints.isEmpty) { _showPageToast(message: "请先完成作业区域打点!", type: ToastType.info); @@ -1106,10 +1270,16 @@ class _MapPageEnterpriseState extends State { // 障碍物模式:添加到当前障碍物打点 _currentObstaclePoints.add(_mapCenter); _isObstacleEditing = true; // 标记进入障碍物编辑状态 - debugPrint('新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}'); + debugPrint( + '新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}', + ); } } else { - if (_currentLatLng == null || !_isValidLatLng(_currentLatLng!.latitude, _currentLatLng!.longitude)) { + if (_currentLatLng == null || + !_isValidLatLng( + _currentLatLng!.latitude, + _currentLatLng!.longitude, + )) { _showPageToast(message: "设备坐标无效,无法添加打点!", type: ToastType.error); //ToastUtils.showError(context, '设备坐标无效,无法添加打点!'); return; @@ -1119,7 +1289,9 @@ class _MapPageEnterpriseState extends State { if (_currentAreaMode == AreaMode.work) { _markedPoints.add(_currentLatLng!); // 从设备实时坐标取最后一个值 _robotModeWgsPoints.add(_currentWgsLatLng!); - debugPrint('Robot模式新增作业打点:第${_markedPoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}'); + debugPrint( + 'Robot模式新增作业打点:第${_markedPoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}', + ); } else { if (_markedPoints.isEmpty || _robotModeWgsPoints.isEmpty) { _showPageToast(message: "请先完成作业区域打点!", type: ToastType.info); @@ -1129,12 +1301,16 @@ class _MapPageEnterpriseState extends State { _currentObstaclePoints.add(_currentLatLng!); _robotModeObsWgsPoints.add(_currentWgsLatLng!); _isObstacleEditing = true; - debugPrint('Robot模式新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}'); + debugPrint( + 'Robot模式新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}', + ); } } }); _saveDataToLocal(); - debugPrint('$_markedPoints,新增打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude.toStringAsFixed(20)}, ${_mapCenter.longitude.toStringAsFixed(20)}'); + debugPrint( + '$_markedPoints,新增打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude.toStringAsFixed(20)}, ${_mapCenter.longitude.toStringAsFixed(20)}', + ); } // ========== 新增:返回上一级页面的方法 ========== @@ -1167,7 +1343,10 @@ class _MapPageEnterpriseState extends State { // 拖拽区域(整个弹窗可拖拽) onPanUpdate: (details) { setState(() { - _videoPopupPos = Offset(_videoPopupPos.dx + details.delta.dx, _videoPopupPos.dy + details.delta.dy); + _videoPopupPos = Offset( + _videoPopupPos.dx + details.delta.dx, + _videoPopupPos.dy + details.delta.dy, + ); }); }, child: WebRTCMapPlayer( @@ -1218,7 +1397,10 @@ class _MapPageEnterpriseState extends State { return; } - final pathRecords = _loadedPlot.where((item) => item is Map).cast>().toList(); + final pathRecords = _loadedPlot + .where((item) => item is Map) + .cast>() + .toList(); if (pathRecords.isEmpty) { setState(() { _isWorkPanelOpen = true; @@ -1251,8 +1433,12 @@ class _MapPageEnterpriseState extends State { return; } - workMode = parsedJson['planModel'] == WorkMode.bow.value ? "弓字模式" : "自定义模式"; - _currentWorkMode = parsedJson['planModel'] == WorkMode.bow.value ? WorkMode.bow : WorkMode.custom; + workMode = parsedJson['planModel'] == WorkMode.bow.value + ? "弓字模式" + : "自定义模式"; + _currentWorkMode = parsedJson['planModel'] == WorkMode.bow.value + ? WorkMode.bow + : WorkMode.custom; // 【优化4】统一 path/outer 解析逻辑,不重复代码 List pathList = []; @@ -1301,7 +1487,9 @@ class _MapPageEnterpriseState extends State { // 【优化6】整个方法只调用一次 setState,性能暴涨 setState(() { - gcjPathPoints = _currentWorkMode == WorkMode.custom ? [] : newPathPoints; + gcjPathPoints = _currentWorkMode == WorkMode.custom + ? [] + : newPathPoints; gcjOuterPoints = newOuterPoints; _isWorkPanelOpen = true; _isPanelOpen = false; @@ -1335,7 +1523,9 @@ class _MapPageEnterpriseState extends State { color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all( - color: _selectedPlot?.id == plot.id ? Colors.blue : Colors.grey[100]!, + color: _selectedPlot?.id == plot.id + ? Colors.blue + : Colors.grey[100]!, width: _selectedPlot?.id == plot.id ? 2 : 1, // 选中项高亮边框 ), ), @@ -1372,7 +1562,11 @@ class _MapPageEnterpriseState extends State { IconButton( onPressed: () => _showDeleteConfirmDialog(plot, onDelete), - icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), + icon: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + size: 20, + ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), @@ -1386,7 +1580,13 @@ class _MapPageEnterpriseState extends State { return const LatLng(30.279651, 120.154871); } - Widget _buildMap(double lat, double lng, bool obfFlag, int headingStatus, String controlMode) { + Widget _buildMap( + double lat, + double lng, + bool obfFlag, + int headingStatus, + String controlMode, + ) { _onDeviceData(lng, lat, obfFlag, headingStatus, controlMode); final gcjPoint = wgs84ToGcj02(lat, lng); @@ -1416,7 +1616,9 @@ class _MapPageEnterpriseState extends State { return FlutterMap( mapController: _mapController, options: MapOptions( - initialCenter: __isValidLatLng(_currentLatLng) ? _currentLatLng! : initCenter, + initialCenter: __isValidLatLng(_currentLatLng) + ? _currentLatLng! + : initCenter, initialZoom: 18, maxZoom: 22, minZoom: 3, @@ -1492,17 +1694,27 @@ class _MapPageEnterpriseState extends State { PolylineLayer( polylines: [ for (int i = 0; i < _markedPoints.length - 1; i++) - Polyline(points: [_markedPoints[i], _markedPoints[i + 1]], color: Colors.orange.withOpacity(0.5), strokeWidth: 1.5), + Polyline( + points: [_markedPoints[i], _markedPoints[i + 1]], + color: Colors.orange.withOpacity(0.5), + strokeWidth: 1.5, + ), ], ), - if (_markedPoints.isNotEmpty && !_isWorkAreaCompleted && _currentRobotMode == RobotMode.point) + if (_markedPoints.isNotEmpty && + !_isWorkAreaCompleted && + _currentRobotMode == RobotMode.point) Stack( // 核心:替换Column为Stack children: [ // 1. 蓝色连线 PolylineLayer( polylines: [ - Polyline(points: [_mapCenter, _markedPoints.last], color: Colors.blue.withOpacity(0.5), strokeWidth: 1.5), + Polyline( + points: [_mapCenter, _markedPoints.last], + color: Colors.blue.withOpacity(0.5), + strokeWidth: 1.5, + ), ], ), // 2. 距离文本Marker @@ -1513,10 +1725,20 @@ class _MapPageEnterpriseState extends State { width: 100, height: 30, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), child: Text( - _calculateLatLngDistance(_mapCenter, _markedPoints.last), - style: const TextStyle(color: Colors.red, fontSize: 12, fontWeight: FontWeight.bold), + _calculateLatLngDistance( + _mapCenter, + _markedPoints.last, + ), + style: const TextStyle( + color: Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), ), @@ -1526,7 +1748,10 @@ class _MapPageEnterpriseState extends State { ], ), - if (_currentWorkMode == WorkMode.bow && _markedPoints.isNotEmpty && !_isWorkAreaCompleted && _currentRobotMode == RobotMode.point) + if (_currentWorkMode == WorkMode.bow && + _markedPoints.isNotEmpty && + !_isWorkAreaCompleted && + _currentRobotMode == RobotMode.point) PolygonLayer( polygons: [ Polygon( @@ -1559,14 +1784,22 @@ class _MapPageEnterpriseState extends State { // 已绘制的障碍物点连线 for (int i = 0; i < _currentObstaclePoints.length - 1; i++) Polyline( - points: [_currentObstaclePoints[i], _currentObstaclePoints[i + 1]], + points: [ + _currentObstaclePoints[i], + _currentObstaclePoints[i + 1], + ], color: Colors.red.withOpacity(0.8), strokeWidth: 1.5, isDotted: true, // 虚线区分作业区域 ), if (_currentRobotMode == RobotMode.point) // 最后一个点到地图中心的连线 - Polyline(points: [_mapCenter, _currentObstaclePoints.last], color: Colors.red.withOpacity(0.5), strokeWidth: 1.5, isDotted: true), + Polyline( + points: [_mapCenter, _currentObstaclePoints.last], + color: Colors.red.withOpacity(0.5), + strokeWidth: 1.5, + isDotted: true, + ), ], ), @@ -1575,8 +1808,16 @@ class _MapPageEnterpriseState extends State { MarkerLayer( markers: [ // 已完成的障碍物打点 - for (var holeIndex = 0; holeIndex < _obstacleHoles.length; holeIndex++) - for (var pointIndex = 0; pointIndex < _obstacleHoles[holeIndex].length; pointIndex++) + for ( + var holeIndex = 0; + holeIndex < _obstacleHoles.length; + holeIndex++ + ) + for ( + var pointIndex = 0; + pointIndex < _obstacleHoles[holeIndex].length; + pointIndex++ + ) Marker( point: _obstacleHoles[holeIndex][pointIndex], width: 80, @@ -1591,12 +1832,18 @@ class _MapPageEnterpriseState extends State { decoration: const BoxDecoration( color: Colors.red, // 红色标记区分作业区域 shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)], + boxShadow: [ + BoxShadow(color: Colors.black12, blurRadius: 2), + ], ), child: Center( child: Text( '${holeIndex + 1}-${pointIndex + 1}', // 格式:组号-点号 - style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold), + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -1604,7 +1851,11 @@ class _MapPageEnterpriseState extends State { ), ), // 正在绘制的障碍物打点 - for (var pointIndex = 0; pointIndex < _currentObstaclePoints.length; pointIndex++) + for ( + var pointIndex = 0; + pointIndex < _currentObstaclePoints.length; + pointIndex++ + ) Marker( point: _currentObstaclePoints[pointIndex], width: 80, @@ -1619,12 +1870,18 @@ class _MapPageEnterpriseState extends State { decoration: const BoxDecoration( color: Colors.orange, // 橙色标记区分正在绘制 shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)], + boxShadow: [ + BoxShadow(color: Colors.black12, blurRadius: 2), + ], ), child: Center( child: Text( '${pointIndex + 1}', - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -1638,7 +1895,13 @@ class _MapPageEnterpriseState extends State { if (gctracePoint != null && gctracePoint!.length >= 2) PolylineLayer( key: const ValueKey('trace_polyline'), - polylines: [Polyline(points: gctracePoint!, color: const Color.fromARGB(255, 6, 187, 232), strokeWidth: 3)], + polylines: [ + Polyline( + points: gctracePoint!, + color: const Color.fromARGB(255, 6, 187, 232), + strokeWidth: 3, + ), + ], ), /// 当前定位 Marker @@ -1679,12 +1942,18 @@ class _MapPageEnterpriseState extends State { decoration: const BoxDecoration( color: Color.fromARGB(255, 10, 218, 55), // 绿色主题色 shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)], + boxShadow: [ + BoxShadow(color: Colors.black12, blurRadius: 2), + ], ), child: Center( child: Text( '$index', - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -1729,11 +1998,17 @@ class _MapPageEnterpriseState extends State { await Future.delayed(const Duration(milliseconds: 100)); debugPrint('⚙️ 开始类型转换...'); - final List typedList = startWorkList.whereType>().map((item) { - return work_area_model.DeviceAddPathPointModel(latitude: (item['lat'] as num).toDouble(), longitude: (item['lon'] ?? item["lng"] as num).toDouble()); - }).toList(); + final List typedList = + startWorkList.whereType>().map((item) { + return work_area_model.DeviceAddPathPointModel( + latitude: (item['lat'] as num).toDouble(), + longitude: (item['lng'] ?? item["lon"] as num).toDouble(), + ); + }).toList(); - final Queue pathQueue = Queue.from(typedList); + final Queue pathQueue = Queue.from( + typedList, + ); // 步骤 3:调用 Cubit 方法(类型匹配) await context.read().startRoutePlanning(pathQueue); @@ -1830,7 +2105,13 @@ class _MapPageEnterpriseState extends State { decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, -2))], + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], ), child: Column( mainAxisSize: MainAxisSize.min, @@ -1851,7 +2132,11 @@ class _MapPageEnterpriseState extends State { width: 80, height: 80, color: Colors.grey[200], - child: const Icon(Icons.image_outlined, color: Colors.grey, size: 32), + child: const Icon( + Icons.image_outlined, + color: Colors.grey, + size: 32, + ), ); }, ), @@ -1863,12 +2148,22 @@ class _MapPageEnterpriseState extends State { children: [ Text( _selectedPlot!.plotName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text('${AppLocalizations.of(context).translate('route_planning.work_mode')}:$workMode', style: const TextStyle(fontSize: 14, color: Colors.grey)), + Text( + '${AppLocalizations.of(context).translate('route_planning.work_mode')}:$workMode', + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), ], ), ), @@ -1889,9 +2184,16 @@ class _MapPageEnterpriseState extends State { //if (isStopWork || !isStartWork) return; }, - icon: const Icon(Icons.close, color: Colors.grey, size: 20), + icon: const Icon( + Icons.close, + color: Colors.grey, + size: 20, + ), padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), ), ], ), @@ -1908,7 +2210,10 @@ class _MapPageEnterpriseState extends State { Expanded( flex: 1, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), margin: const EdgeInsets.only(right: 6, bottom: 12), decoration: BoxDecoration( color: Colors.red[50], @@ -1918,12 +2223,19 @@ class _MapPageEnterpriseState extends State { child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.error_outline, color: Colors.redAccent, size: 16), + Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 16, + ), SizedBox(width: 8), Expanded( child: Text( "路径区域为空", - style: TextStyle(fontSize: 14, color: Colors.redAccent), + style: TextStyle( + fontSize: 14, + color: Colors.redAccent, + ), softWrap: true, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -1939,7 +2251,10 @@ class _MapPageEnterpriseState extends State { Expanded( flex: 1, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), margin: const EdgeInsets.only(left: 6, bottom: 12), decoration: BoxDecoration( color: Colors.red[50], @@ -1949,12 +2264,19 @@ class _MapPageEnterpriseState extends State { child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.error_outline, color: Colors.redAccent, size: 16), + Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 16, + ), SizedBox(width: 8), Expanded( child: Text( "航向信息异常", - style: TextStyle(fontSize: 14, color: Colors.redAccent), + style: TextStyle( + fontSize: 14, + color: Colors.redAccent, + ), softWrap: true, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -1974,7 +2296,10 @@ class _MapPageEnterpriseState extends State { width: double.infinity, height: 50, child: ElevatedButton( - onPressed: state.isLoading || startWorkList.isEmpty || headingStatus == 0 + onPressed: + state.isLoading || + startWorkList.isEmpty || + headingStatus == 0 ? null : () { _startWork(); @@ -1982,14 +2307,31 @@ class _MapPageEnterpriseState extends State { style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF00C853), foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), elevation: 2, disabledBackgroundColor: Colors.grey[300], disabledForegroundColor: Colors.grey[600], ), child: state.isLoading - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : Text(AppLocalizations.of(context).translate('route_planning.start_work'), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + AppLocalizations.of( + context, + ).translate('route_planning.start_work'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), ), ) : Row( @@ -2000,18 +2342,28 @@ class _MapPageEnterpriseState extends State { width: 50, height: 50, child: ElevatedButton( - onPressed: () => _workStatus == WorkStatus.working ? _pauseWork() : _resumeWork(), + onPressed: () => + _workStatus == WorkStatus.working + ? _pauseWork() + : _resumeWork(), style: ElevatedButton.styleFrom( - backgroundColor: _workStatus == WorkStatus.working ? Colors.amber : const Color(0xFF00C853), + backgroundColor: + _workStatus == WorkStatus.working + ? Colors.amber + : const Color(0xFF00C853), foregroundColor: Colors.white, shape: const CircleBorder(), elevation: 2, // 调整内边距,让图标居中更协调 - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), ), child: Icon( // 作业中显示暂停图标,暂停中显示播放(继续)图标 - _workStatus == WorkStatus.working ? Icons.pause : Icons.play_arrow, + _workStatus == WorkStatus.working + ? Icons.pause + : Icons.play_arrow, size: 24, // 图标尺寸 ), ), @@ -2030,7 +2382,9 @@ class _MapPageEnterpriseState extends State { foregroundColor: Colors.white, shape: const CircleBorder(), elevation: 2, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), ), child: const Icon( Icons.stop, // 停止图标(也可以用 Icons.close) @@ -2075,7 +2429,9 @@ class _MapPageEnterpriseState extends State { Future _captureMapToJpgBase64({int quality = 80}) async { try { // 1. 第一步:截取 PNG (Flutter 唯一支持的格式) - RenderRepaintBoundary? boundary = _mapRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + RenderRepaintBoundary? boundary = + _mapRepaintKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; if (boundary == null) { debugPrint('截图失败:渲染对象未找到'); return null; @@ -2083,7 +2439,9 @@ class _MapPageEnterpriseState extends State { // 🔥 注意:这里是 dart:ui 的 Image,需显式指定 ui.Image ui.Image image = await boundary.toImage(pixelRatio: 2.0); - ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); + ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); if (byteData == null) { debugPrint('截图失败:字节数据为空'); return null; @@ -2113,10 +2471,14 @@ class _MapPageEnterpriseState extends State { } // 🔥 改动4:新增方法 - 将Bloc的workRecords转换为PlotData列表 - List _convertWorkRecordsToPlotData(List> records) { + List _convertWorkRecordsToPlotData( + List> records, + ) { return records.map((record) { return PlotData( - id: record['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), // 唯一ID + id: + record['id']?.toString() ?? + DateTime.now().microsecondsSinceEpoch.toString(), // 唯一ID plotName: record['workName'] ?? '未命名地块', // 地块名称(从接口字段取) imageUrl: record['imgUrl'] ?? '', // 图片URL(从接口字段取,无则为空) jsonData: record['jsonData'], // 原始数据的JSON字符串(可选,便于调试或后续使用) @@ -2158,11 +2520,16 @@ class _MapPageEnterpriseState extends State { try { LatLng firstPointWgs; - if (_currentRobotMode == RobotMode.robot && _robotModeWgsPoints.isNotEmpty) { + if (_currentRobotMode == RobotMode.robot && + _robotModeWgsPoints.isNotEmpty) { firstPointWgs = _robotModeWgsPoints.first; - } else if (_currentRobotMode == RobotMode.point && _markedPoints.isNotEmpty) { + } else if (_currentRobotMode == RobotMode.point && + _markedPoints.isNotEmpty) { LatLng firstPointGcj = _markedPoints.first; - firstPointWgs = gcj02ToWgs84(firstPointGcj.latitude, firstPointGcj.longitude); + firstPointWgs = gcj02ToWgs84( + firstPointGcj.latitude, + firstPointGcj.longitude, + ); } else { if (showTips) { _showPageToast(message: '参考点坐标为空,请先添加作业区域打点!', type: ToastType.error); @@ -2170,18 +2537,29 @@ class _MapPageEnterpriseState extends State { return; } - final referencePoint = work_area_model.ReferencePoint(lat: firstPointWgs.latitude, lon: firstPointWgs.longitude); + final referencePoint = work_area_model.ReferencePoint( + lat: firstPointWgs.latitude, + lon: firstPointWgs.longitude, + ); final heading = _angle == -1 ? -1 : _angle.toInt(); List outerPositions = []; - if (_currentRobotMode == RobotMode.robot && _robotModeWgsPoints.isNotEmpty) { + if (_currentRobotMode == RobotMode.robot && + _robotModeWgsPoints.isNotEmpty) { outerPositions = _robotModeWgsPoints.map((latLng) { - return work_area_model.Position(lat: latLng.latitude, lon: latLng.longitude); + return work_area_model.Position( + lat: latLng.latitude, + lon: latLng.longitude, + ); }).toList(); - } else if (_currentRobotMode == RobotMode.point && _markedPoints.isNotEmpty) { + } else if (_currentRobotMode == RobotMode.point && + _markedPoints.isNotEmpty) { outerPositions = _markedPoints.map((latLng) { final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude); - return work_area_model.Position(lat: wgs84Point.latitude, lon: wgs84Point.longitude); + return work_area_model.Position( + lat: wgs84Point.latitude, + lon: wgs84Point.longitude, + ); }).toList(); } gcjOuterPoints = outerPositions.map((point) { @@ -2191,32 +2569,49 @@ class _MapPageEnterpriseState extends State { return LatLng(gcjPoint.latitude, gcjPoint.longitude); }).toList(); - final outerBoundary = work_area_model.OuterBoundary(position: outerPositions, sideWidth: _workDistance); + final outerBoundary = work_area_model.OuterBoundary( + position: outerPositions, + sideWidth: _workDistance, + ); work_area_model.HoleBoundary? holeBoundary; if (_obstacleHoles.isNotEmpty) { List> positionList = []; - if (_currentRobotMode == RobotMode.robot && _robotModeObsWgsPoints.isNotEmpty) { + if (_currentRobotMode == RobotMode.robot && + _robotModeObsWgsPoints.isNotEmpty) { for (var holePoints in _obstacleWgsHoles) { List innerPositionList = []; innerPositionList = holePoints.map((latLng) { - return work_area_model.Position(lat: latLng.latitude, lon: latLng.longitude); + return work_area_model.Position( + lat: latLng.latitude, + lon: latLng.longitude, + ); }).toList(); positionList.add(innerPositionList); } - } else if (_currentRobotMode == RobotMode.point && _obstacleHoles.isNotEmpty) { + } else if (_currentRobotMode == RobotMode.point && + _obstacleHoles.isNotEmpty) { for (var holePoints in _obstacleHoles) { List innerPositionList = []; innerPositionList = holePoints.map((latLng) { - final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude); - return work_area_model.Position(lat: wgs84Point.latitude, lon: wgs84Point.longitude); + final wgs84Point = gcj02ToWgs84( + latLng.latitude, + latLng.longitude, + ); + return work_area_model.Position( + lat: wgs84Point.latitude, + lon: wgs84Point.longitude, + ); }).toList(); positionList.add(innerPositionList); } } - holeBoundary = work_area_model.HoleBoundary(position: positionList, sideWidth: 0.0); + holeBoundary = work_area_model.HoleBoundary( + position: positionList, + sideWidth: 0.0, + ); } Map holes = {}; @@ -2237,7 +2632,10 @@ class _MapPageEnterpriseState extends State { if (cubitState.errorMessage != null) { if (showTips) { - _showPageToast(message: '路径生成失败:${cubitState.errorMessage}', type: ToastType.error); + _showPageToast( + message: '路径生成失败:${cubitState.errorMessage}', + type: ToastType.error, + ); } } else if (cubitState.generatedPath != null) { if (showTips) { @@ -2250,7 +2648,9 @@ class _MapPageEnterpriseState extends State { _currentObstaclePoints.clear(); _robotModeObsWgsPoints.clear(); final pathList = cubitState.generatedPath as List; - typedPathList = pathList.whereType().toList(); + typedPathList = pathList + .whereType() + .toList(); gcjPathPoints = typedPathList.map((point) { double wgs84Lat = point.latitude; double wgs84Lon = point.longitude; @@ -2265,7 +2665,10 @@ class _MapPageEnterpriseState extends State { } catch (e) { debugPrint('路径生成异常:$e'); if (showTips) { - _showPageToast(message: '路径生成异常:${e.toString().substring(0, 50)}', type: ToastType.error); + _showPageToast( + message: '路径生成异常:${e.toString().substring(0, 50)}', + type: ToastType.error, + ); } } } @@ -2293,8 +2696,14 @@ class _MapPageEnterpriseState extends State { child: Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration(color: _getToastColor(type), borderRadius: BorderRadius.circular(8)), - child: Text(message, style: const TextStyle(color: Colors.white, fontSize: 14)), + decoration: BoxDecoration( + color: _getToastColor(type), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), ), ), ), @@ -2332,10 +2741,17 @@ class _MapPageEnterpriseState extends State { final menuHeight = 16 * 6; // 假设 VerticalFloatMenu 有 6 个选项,每个高度为 56 final maxTop = screenHeight - menuHeight; final userState = context.watch().state; - final deviceId = context.watch().state.selectedDevice?.deviceName; + final deviceId = context + .watch() + .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}"; + if (deviceId != null && + userState.user != null && + userState.user!.token != null) { + _videoStreamUrl = + "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}"; } else { _videoStreamUrl = ''; } @@ -2348,7 +2764,9 @@ class _MapPageEnterpriseState extends State { LatLng? targetLatLng; if (arriLatitude != null && arriLongitude != null) { targetLatLng = LatLng(arriLatitude, arriLongitude); - _logger.log("当前轨迹模式: ${_traceManager.getMode()},收到完成点坐标: $targetLatLng"); + _logger.log( + "当前轨迹模式: ${_traceManager.getMode()},收到完成点坐标: $targetLatLng", + ); if (__isValidLatLng(targetLatLng)) { //debugPrint("${targetLatLng}收到完成点arriLatitude"); //if (!isreceiveFirstCompletePoint && isStartWork && _workStatus == WorkStatus.working) { @@ -2423,7 +2841,16 @@ class _MapPageEnterpriseState extends State { body: SafeArea( child: Stack( children: [ - RepaintBoundary(key: _mapRepaintKey, child: _buildMap(currentLat, currentLng, obfFlag, headingStatus, controlMode)), + RepaintBoundary( + key: _mapRepaintKey, + child: _buildMap( + currentLat, + currentLng, + obfFlag, + headingStatus, + controlMode, + ), + ), // 地图核心组件 if (_isRefreshing) @@ -2433,7 +2860,12 @@ class _MapPageEnterpriseState extends State { child: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [CircularProgressIndicator(color: Colors.white, strokeWidth: 3)], + children: [ + CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ], ), ), ), @@ -2453,7 +2885,13 @@ class _MapPageEnterpriseState extends State { decoration: BoxDecoration( color: Colors.white.withOpacity(0.8), borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 3, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], ), // 核心:用 Stack 实现点击高亮层 + 图标层 child: Stack( @@ -2469,7 +2907,11 @@ class _MapPageEnterpriseState extends State { ), ), // 2. 图标层(绝对居中) - const Icon(Icons.arrow_back_ios, color: Colors.black87, size: 20), + const Icon( + Icons.arrow_back_ios, + color: Colors.black87, + size: 20, + ), ], ), ), @@ -2506,13 +2948,23 @@ class _MapPageEnterpriseState extends State { decoration: BoxDecoration( color: const Color(0xFF00C853), shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: IconButton( onPressed: () { _showSavePlotDialog(); }, - icon: const Icon(Icons.save, color: Colors.white, size: 18), + icon: const Icon( + Icons.save, + color: Colors.white, + size: 18, + ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), @@ -2535,7 +2987,9 @@ class _MapPageEnterpriseState extends State { }, onListBox: (bool isOpen) { // 🔥 改动5:加载作业记录(原有逻辑保留) - final userId = context.read().state.user?.userId ?? ""; + final userId = + context.read().state.user?.userId ?? + ""; print('加载作业记录,当前用户ID:$userId'); context.read().loadWorkRecords(userId); @@ -2578,8 +3032,14 @@ class _MapPageEnterpriseState extends State { initialRobotMode: _currentRobotMode, initialAreaMode: _currentAreaMode, initialWorkMode: _currentWorkMode!, - isShowButton: _currentWorkMode == WorkMode.bow ? true : false, - canUndo: _isWorkAreaCompleted && _currentAreaMode == AreaMode.work ? false : true, + isShowButton: _currentWorkMode == WorkMode.bow + ? true + : false, + canUndo: + _isWorkAreaCompleted && + _currentAreaMode == AreaMode.work + ? false + : true, onComplete: () async { debugPrint( '操作完成回调:当前机器人模式=$_currentRobotMode,当前区域模式=$_currentAreaMode,当前作业模式:$_currentWorkMode,作业区域点:$_markedPoints,作业行距=$_workDistance,航线方向角=$_angle', @@ -2617,8 +3077,11 @@ class _MapPageEnterpriseState extends State { }, onDistanceTap: (distance) { _workDistance = distance; - debugPrint('外部处理作业行距距离设置,当前距离:${distance.toStringAsFixed(1)}米'); - if (_markedPoints.length >= 3 && _currentWorkMode != null) { + debugPrint( + '外部处理作业行距距离设置,当前距离:${distance.toStringAsFixed(1)}米', + ); + if (_markedPoints.length >= 3 && + _currentWorkMode != null) { _generatePath(showTips: false); } }, @@ -2645,13 +3108,22 @@ class _MapPageEnterpriseState extends State { child: RouteDirectionPanel( initialOptimalHeading: true, initialDirection: 0.0, - titleText: AppLocalizations.of(context).translate('route_planning.route_direction'), - optimalHeadingText: AppLocalizations.of(context).translate('route_planning.optimal_heading'), - routeDirectionText: AppLocalizations.of(context).translate('route_planning.route_direction'), + titleText: AppLocalizations.of( + context, + ).translate('route_planning.route_direction'), + optimalHeadingText: AppLocalizations.of( + context, + ).translate('route_planning.optimal_heading'), + routeDirectionText: AppLocalizations.of( + context, + ).translate('route_planning.route_direction'), onValueChanged: (result) { - _angle = result['optimalHeading'] == true ? -1 : result['direction']; + _angle = result['optimalHeading'] == true + ? -1 + : result['direction']; - if (_markedPoints.length >= 3 && _currentWorkMode != null) { + if (_markedPoints.length >= 3 && + _currentWorkMode != null) { _generatePath(showTips: false); } }, @@ -2665,7 +3137,9 @@ class _MapPageEnterpriseState extends State { if (_isListBoxOpen) BlocBuilder( builder: (context, state) { - final plotList = _convertWorkRecordsToPlotData(state.workRecords ?? []); + final plotList = _convertWorkRecordsToPlotData( + state.workRecords ?? [], + ); return Positioned.fill( child: Column( @@ -2678,27 +3152,55 @@ class _MapPageEnterpriseState extends State { _isListBoxOpen = false; }); }, - child: Container(color: Colors.black.withOpacity(0.3)), + child: Container( + color: Colors.black.withOpacity(0.3), + ), ), ), Container( width: double.infinity, decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, -2))], + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], + ), + constraints: const BoxConstraints( + maxHeight: 600, + minHeight: 200, ), - constraints: const BoxConstraints(maxHeight: 600, minHeight: 200), child: Column( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ Text( - AppLocalizations.of(context).translate('route_planning.plot_list_title').replaceAll('%d', plotList.length.toString()), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87), + AppLocalizations.of(context) + .translate( + 'route_planning.plot_list_title', + ) + .replaceAll( + '%d', + plotList.length.toString(), + ), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), ), IconButton( onPressed: () { @@ -2706,7 +3208,11 @@ class _MapPageEnterpriseState extends State { _isListBoxOpen = false; }); }, - icon: const Icon(Icons.close, color: Colors.grey, size: 20), + icon: const Icon( + Icons.close, + color: Colors.grey, + size: 20, + ), ), ], ), @@ -2716,24 +3222,55 @@ class _MapPageEnterpriseState extends State { child: plotList.isEmpty ? Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: + MainAxisAlignment.center, children: [ - const Icon(Icons.inbox_outlined, color: Colors.grey, size: 48), + const Icon( + Icons.inbox_outlined, + color: Colors.grey, + size: 48, + ), const SizedBox(height: 16), - Text(AppLocalizations.of(context).translate('route_planning.no_plot_data'), style: const TextStyle(color: Colors.grey, fontSize: 16)), + Text( + AppLocalizations.of( + context, + ).translate( + 'route_planning.no_plot_data', + ), + style: const TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), ], ), ) : ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric( + vertical: 8, + ), itemCount: plotList.length, itemBuilder: (context, index) { final plot = plotList[index]; // 传入删除回调(调用Bloc的删除方法) - return _buildPlotListItem(plot, (deletedPlot) async { - await context.read().deleteWorkRecord(deletedPlot.plotName); - final userId = context.read().state.user?.userId ?? ""; - await context.read().loadWorkRecords(userId); + return _buildPlotListItem(plot, ( + deletedPlot, + ) async { + await context + .read() + .deleteWorkRecord( + deletedPlot.plotName, + ); + final userId = + context + .read() + .state + .user + ?.userId ?? + ""; + await context + .read() + .loadWorkRecords(userId); setState(() {}); }); @@ -2754,18 +3291,39 @@ class _MapPageEnterpriseState extends State { left: 20, right: 20, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Colors.orange.withOpacity(0.9), borderRadius: BorderRadius.circular(8), - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: Row( children: [ - const Icon(Icons.warning_amber_rounded, color: Colors.white, size: 20), + const Icon( + Icons.warning_amber_rounded, + color: Colors.white, + size: 20, + ), const SizedBox(width: 8), Expanded( - child: Text(AppLocalizations.of(context).translate('route_planning.heading_not_init'), style: const TextStyle(color: Colors.white, fontSize: 14)), + child: Text( + AppLocalizations.of( + context, + ).translate('route_planning.heading_not_init'), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), ), ], ), @@ -2779,18 +3337,39 @@ class _MapPageEnterpriseState extends State { left: 20, right: 20, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Colors.red.withOpacity(0.9), borderRadius: BorderRadius.circular(8), - boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: Row( children: [ - const Icon(Icons.error_rounded, color: Colors.white, size: 20), + const Icon( + Icons.error_rounded, + color: Colors.white, + size: 20, + ), const SizedBox(width: 8), Expanded( - child: Text(AppLocalizations.of(context).translate('route_planning.switch_remote_mode'), style: const TextStyle(color: Colors.white, fontSize: 14)), + child: Text( + AppLocalizations.of( + context, + ).translate('route_planning.switch_remote_mode'), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), ), ], ), @@ -2918,7 +3497,13 @@ bool _outOfChina(double lat, double lon) { } double _transformLat(double x, double y) { - double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(x.abs()); + double ret = + -100.0 + + 2.0 * x + + 3.0 * y + + 0.2 * y * y + + 0.1 * x * y + + 0.2 * sqrt(x.abs()); ret += (20.0 * sin(6.0 * x * _pi) + 20.0 * sin(2.0 * x * _pi)) * 2.0 / 3.0; ret += (20.0 * sin(y * _pi) + 40.0 * sin(y / 3.0 * _pi)) * 2.0 / 3.0; ret += (160.0 * sin(y / 12.0 * _pi) + 320 * sin(y * _pi / 30.0)) * 2.0 / 3.0; @@ -2926,10 +3511,12 @@ double _transformLat(double x, double y) { } double _transformLon(double x, double y) { - double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(x.abs()); + double ret = + 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(x.abs()); ret += (20.0 * sin(6.0 * x * _pi) + 20.0 * sin(2.0 * x * _pi)) * 2.0 / 3.0; ret += (20.0 * sin(x * _pi) + 40.0 * sin(x / 3.0 * _pi)) * 2.0 / 3.0; - ret += (150.0 * sin(x / 12.0 * _pi) + 300.0 * sin(x / 30.0 * _pi)) * 2.0 / 3.0; + ret += + (150.0 * sin(x / 12.0 * _pi) + 300.0 * sin(x / 30.0 * _pi)) * 2.0 / 3.0; return ret; } @@ -2946,7 +3533,9 @@ String _calculateLatLngDistance(LatLng point1, LatLng point2) { // 哈维正弦公式(Haversine)计算地球表面两点实际距离 double dLat = lat2Rad - lat1Rad; double dLng = lng2Rad - lng1Rad; - double a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1Rad) * cos(lat2Rad) * sin(dLng / 2) * sin(dLng / 2); + double a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1Rad) * cos(lat2Rad) * sin(dLng / 2) * sin(dLng / 2); double c = 2 * atan2(sqrt(a), sqrt(1 - a)); double distance = earthRadius * c; diff --git a/lib/features/main_container/presentation/pages/custom_main_container.dart b/lib/features/main_container/presentation/pages/custom_main_container.dart index bab1105e..88ad92ff 100644 --- a/lib/features/main_container/presentation/pages/custom_main_container.dart +++ b/lib/features/main_container/presentation/pages/custom_main_container.dart @@ -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 } 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, diff --git a/lib/features/remote_control/data/datasources/remote_http_datasource.dart b/lib/features/remote_control/data/datasources/remote_http_datasource.dart index 7a1ed6d1..15815057 100644 --- a/lib/features/remote_control/data/datasources/remote_http_datasource.dart +++ b/lib/features/remote_control/data/datasources/remote_http_datasource.dart @@ -27,19 +27,23 @@ class RemoteHttpDatasource { }*/ - Future requestRemoteControlViaHttp( String deviceId, String currentDeviceId) async { + /// 🔥 返回完整权限信息: {hasPermission, owner} + Future> 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?; 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}; } } diff --git a/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart b/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart index b567b00a..ce6f06e2 100644 --- a/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart +++ b/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart @@ -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,11 +16,12 @@ import '../../../../core/storage/user_storage.dart'; class RemoteTcpDatasource { final TcpClient _tcpClient; final UserStorage _userStorage; + final ILoggerService _logger = GetIt.I(); RemoteTcpDatasource(this._tcpClient, this._userStorage); /// 发送 switch_control 权限请求(对应 Android 的 requestSwitchControl) - /// + /// /// 这是获取设备控制权的第一步: /// 1. 先发送 TCP 0x12 指令携带 switch_control 请求 /// 2. 等待服务端响应 @@ -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; } } diff --git a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart index 7ea9f258..e97762cc 100644 --- a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart +++ b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart @@ -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(); - - 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( @@ -45,41 +50,104 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository { ignition: status.ignitionStatus, emergency: status.isEmergency ? 1 : 0, ); - + // 3. 调用 TcpClient 发送指令 - _tcpClient.sendRaw( - MachineProtocolConstants.cmdRemoteControl, - payload, - ); + _tcpClient.sendRaw(MachineProtocolConstants.cmdRemoteControl, payload); } @override Stream get responseStream => _tcpClient.packetStream; @override - Future requestControlPermission( String deviceName, String deviceId) async { + void sendTcpPermissionRequest(String deviceName) { + _remoteTcp.sendSwitchControlRequest(deviceName); + _logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求'); + } + + @override + Future> 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 releasePermission(String platform) async { return await _remoteHttp.releaseRemoteControlViaHttp(platform); diff --git a/lib/features/remote_control/domain/repositories/remote_control_repository.dart b/lib/features/remote_control/domain/repositories/remote_control_repository.dart index d72e4dcd..10071a64 100644 --- a/lib/features/remote_control/domain/repositories/remote_control_repository.dart +++ b/lib/features/remote_control/domain/repositories/remote_control_repository.dart @@ -13,8 +13,12 @@ abstract class RemoteControlRepository { /// WebView 视角控制 (逻辑层面的切换,不涉及 TCP) void changeWebViewDirection(String direction); - /// 新增:请求控制权限 (发送 0x04 指令) - Future requestControlPermission(String deviceName, String deviceId); + /// 新增:请求控制权限 (HTTP接口返回完整权限信息) + /// 返回: {hasPermission: bool, owner: String?} + Future> requestControlPermission(String deviceName, String deviceId); + + /// 🔥 新增: 发送 TCP 0x12 权限请求指令 + void sendTcpPermissionRequest(String deviceName); /// 新增:响应控制权限 同意和拒绝(发送 0x05 指令) void respondPermission(bool bool, String deviceId); diff --git a/lib/features/remote_control/domain/usecase/remote_control_usecase.dart b/lib/features/remote_control/domain/usecase/remote_control_usecase.dart index c239c124..103b2e64 100644 --- a/lib/features/remote_control/domain/usecase/remote_control_usecase.dart +++ b/lib/features/remote_control/domain/usecase/remote_control_usecase.dart @@ -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 { +// 注意:RequestControlPermissionUseCase 和 RequestControlPermissionParams +// 已经在专门的文件中定义 (request_control_permission_usecase.dart) + +class RemoteControlUseCase + implements BaseUseCase, NoParams> { final RemoteControlRepository remoteControlRepository; - RequestControlPermissionUseCase(this.remoteControlRepository); + RemoteControlUseCase(this.remoteControlRepository); @override - Future> call(RequestControlPermissionParams params) async { + Future>> 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, - }); -} diff --git a/lib/features/remote_control/domain/usecase/request_control_permission_usecase.dart b/lib/features/remote_control/domain/usecase/request_control_permission_usecase.dart new file mode 100644 index 00000000..888eccf3 --- /dev/null +++ b/lib/features/remote_control/domain/usecase/request_control_permission_usecase.dart @@ -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, RequestControlPermissionParams> { + final RemoteControlRepository repository; + + RequestControlPermissionUseCase(this.repository); + + @override + Future>> 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, + }); +} \ No newline at end of file diff --git a/lib/features/remote_control/presentation/bloc/control_mode.dart b/lib/features/remote_control/presentation/bloc/control_mode.dart new file mode 100644 index 00000000..05707797 --- /dev/null +++ b/lib/features/remote_control/presentation/bloc/control_mode.dart @@ -0,0 +1 @@ +enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER } \ No newline at end of file diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index d1a229cd..d6d2b708 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -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 { final RemoteControlRepository _repository; final RequestControlPermissionUseCase _requestControlPermissionUseCase; + final DeviceRepository _deviceRepository; // 🔥 注入设备仓库 Timer? _timer; StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期 final NetMessageDispatcher dispatcher; @@ -30,16 +34,28 @@ class RemoteControlCubit extends Cubit { static const platform = MethodChannel('com.maibu.satabot/ping'); int _currentPing = 50; final ILoggerService _logger = GetIt.I(); - + // 🔥 参考Android版:使用成员变量存储摇杆值,避免state竞态 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 { _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 { // }); // } Future _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 { 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 { // 🔥 安全检查:无权限或急停时不发送 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; } @@ -239,28 +347,34 @@ class RemoteControlCubit extends Cubit { ignitionStatus: state.controlEntity.ignitionStatus, isEmergency: state.isEmergency, ); - + // 常规安全检查 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 { // 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,85 +400,93 @@ class RemoteControlCubit extends Cubit { 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; - + // 同时更新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; - + // 同时更新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 stopAllMovement() async { - debugPrint('🛑 [stopAllMovement] 开始执行 - 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY'); - + // debugPrint( + // '🛑 [stopAllMovement] 开始执行- 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY', + // ); + // 🔥 参考Android版:直接清零成员变量 _currentOriginX = 0; _currentOriginY = 0; - + // 🔥 关键修复:先暂停定时器,防止定时器在停止期间发送旧的运动指令 _timer?.cancel(); - _timer = null; // 🔥 彻底清空,防止重复启动 - + _timer = null; // 🔥 彻底清空,防止重复启用 + final updatedEntity = state.controlEntity.copyWith(x: 0, y: 0); - + // 一次性更新双轴(即使无权限也要更新本地state) 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 _sendStopCommandRepeatedly(MachineControlStatusEntity stopEntity) async { - debugPrint('🛑 [紧急停止] 开始连续发送30次停止指令 - originX=${stopEntity.originX}, originY=${stopEntity.originY}'); - // 🔥 注意:_lastStopTime 已经在 stopAllMovement() 中设置了,这里不需要再设置 - + + /// 🔥 统一方法:连续发送10次停止指令,彻底清空TCP缓冲区 + Future _sendStopCommandRepeatedly( + MachineControlStatusEntity stopEntity, + ) async { + // debugPrint( + // '🛑 [紧急停止] 开始连续发送10次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}', + // ); + // 🔥 注意:_lastStopTime 已经在stopAllMovement() 中设置了,这里不需要再设置 + int sentCount = 0; // 🔥 关键修复:每次发送间隔5ms,避免TCP合并/丢弃 for (int i = 0; i < 20; i++) { _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); @@ -371,8 +495,8 @@ class RemoteControlCubit extends Cubit { await Future.delayed(const Duration(milliseconds: 5)); } } - - debugPrint('✅ [紧急停止] 所有30次停止指令已发出'); + + // debugPrint('>>> [紧急停止] 所有20次停止指令已发出'); } // 5. 停止控制循环 @@ -396,7 +520,8 @@ class RemoteControlCubit extends Cubit { } 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 close() { @@ -408,34 +533,85 @@ class RemoteControlCubit extends Cubit { 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 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 { // 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 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 { Future 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 { return 9999; } } -//app退出远程遥控界面释放权限 + + //app退出远程遥控界面释放权限 Future 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)); + } } - diff --git a/lib/features/remote_control/presentation/bloc/remote_control_state.dart b/lib/features/remote_control/presentation/bloc/remote_control_state.dart index f94c2708..bfaa0229 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_state.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_state.dart @@ -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,14 +121,17 @@ class RemoteControlState extends Equatable { permissionPlatform, currentPlatform, showPermissionRequestDialog, + requestingDeviceId, + requestingPlatform, showLeftPip, showRightPip, runningStatusModel, obstacleFlag, topRightIsExpanded, obstacleRecognitionFlag, + targetDevice, ]; -} +} \ No newline at end of file diff --git a/lib/features/remote_control/presentation/data/models/running_status_model.dart b/lib/features/remote_control/presentation/data/models/running_status_model.dart new file mode 100644 index 00000000..b240a6d6 --- /dev/null +++ b/lib/features/remote_control/presentation/data/models/running_status_model.dart @@ -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, + ); + } +} \ No newline at end of file diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index 68068429..f584cef1 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -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 { String _videoStreamUrl = ""; late RemoteControlCubit _cubit; DevicesCubit? _devicesCubit; + StreamSubscription? _permissionSubscription; // 🔥 权限监听订阅 + bool _isShowingPermissionDialog = false; // 🔥 防止弹窗重复显示 + bool _isLoadingPermission = true; // 🔥 标记是否正在加载权限状态 @override void didChangeDependencies() { super.didChangeDependencies(); _cubit = context.read(); _devicesCubit = context.read(); + + // 🔥 监听权限弹窗状态变化 (只订阅一次) + 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(); - final devicesCubit = context.read(); + // 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 { final deviceState = context.watch().state; final currentDevice = deviceState.selectedDevice; + final remoteCubit = context.read(); + final hasPermission = remoteCubit.state.hasPermission; + ///context.read().requestControlPermissionS(currentDevice!.deviceName, "app"); if (currentDevice == null) { return _buildOfflineScaffold(); } - context.read().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 { Positioned.fill( child: BlocBuilder( // 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 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().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() + .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().state.controlEntity.originY; - debugPrint("${originY},originY"); + final int originY = context + .watch() + .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().state.controlEntity.originY >= 0, + isFrontMain: + context + .watch() + .state + .controlEntity + .originY >= + 0, ); }, ), @@ -118,14 +208,44 @@ class _RemoteControlPageState extends State { BlocBuilder( 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( + 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 { final double centerAreaWidth = constraints.maxWidth * 0.5; // 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page - return BlocBuilder( + return BlocBuilder< + RemoteControlCubit, + RemoteControlState + >( // 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理 buildWhen: (p, c) => p.isLocked != c.isLocked, builder: (context, remoteState) { @@ -148,7 +271,10 @@ class _RemoteControlPageState extends State { 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 { width: centerAreaWidth, child: Align( alignment: Alignment.bottomCenter, - child: CenterControlArea(totalWidth: centerAreaWidth), + child: CenterControlArea( + totalWidth: centerAreaWidth, + ), ), ), @@ -166,7 +294,10 @@ class _RemoteControlPageState extends State { 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 { ], ), ), - // 权限弹窗 - BlocBuilder( - buildWhen: (p, c) => p.showPermissionRequestDialog != c.showPermissionRequestDialog, - builder: (context, state) { - if (state.showPermissionRequestDialog) { - return _buildPermissionDialog(context, state); + ], + ), + ); + } + + // 🔥 显示权限请求弹窗 (使用 showDialog 替代 Overlay) + Future _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() + .state + .selectedDevice + ?.deviceName ?? + ""; + final remoteCubit = context.read(); + + // 🔥 添加详细状态日志 - 检查为什么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() + .state + .selectedDevice + ?.deviceName ?? + ""; + final remoteCubit = context.read(); + + // 🔥 添加详细状态日志 - 检查为什么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 { 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().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().respondPermission(false, deviceId), child: Text(AppLocalizations.of(context).translate('remote_control.refuse'))), TextButton( - onPressed: () { - context.read().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('确定'), ), ], ), diff --git a/lib/features/remote_control/presentation/widgets/top_status_bar.dart b/lib/features/remote_control/presentation/widgets/top_status_bar.dart index 7a65a8ce..37365e02 100644 --- a/lib/features/remote_control/presentation/widgets/top_status_bar.dart +++ b/lib/features/remote_control/presentation/widgets/top_status_bar.dart @@ -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().state; - final device = deviceState.selectedDevice; + // 🔥 添加调试日志:确认接收到的状态值 + //debugPrint('🔍 [TopStatusBar] build - isLocked=${remoteState.isLocked}'); + + // 监听全局设备状态 (DevicesCubit 需要 watch,因为设备选择可能变化) + // 🔥 使用 try-catch 保护,防止 BlocProvider 缺失导致整个 widget 树崩溃(白屏) + DeviceEntity? device; + try { + final deviceState = context.watch().state; + device = deviceState.selectedDevice; + } catch (e) { + debugPrint('⚠️ [TopStatusBar] 读取 DevicesCubit 失败: $e'); + // DevicesCubit 不可用时使用 targetDevice 作为降级方案 + try { + device = context.read().state.targetDevice; + } catch (e2) { + debugPrint('⚠️ [TopStatusBar] 读取 targetDevice 也失败: $e2'); + } + } var _remoteControlCubit = context.read(); ControlMode _parseControlMode(String modeString) { switch (modeString.toUpperCase()) { @@ -39,124 +59,171 @@ class TopStatusBar extends StatelessWidget { } } - // 监听局部遥控状态 - final remoteState = context.watch().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().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().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().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; } } diff --git a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart index 79434b10..0c467d51 100644 --- a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart +++ b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart @@ -53,6 +53,13 @@ class _WebRTCLocalPlayerState extends State { } Future _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 { 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"); diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart index 6b8919c7..bd3e7312 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart @@ -1,4 +1,5 @@ import '../../domain/entities/drone_station_entity.dart'; +import '../../domain/entities/video_stream_entity.dart'; abstract class DroneStationDataSource { Future> getDroneStationList(int siteId); @@ -10,4 +11,4 @@ abstract class DroneStationDataSource { String qualityType = 'adaptive', int videoExpire = 7200, }); -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart index 4266268a..b3af494d 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart @@ -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; @@ -83,4 +84,4 @@ class DroneStationDataSourceImpl implements DroneStationDataSource { return VideoStreamEntity.fromJson(responseData['data']); } -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart index 1d404cb3..20f4479d 100644 --- a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart @@ -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 { @@ -51,4 +52,4 @@ class DroneStationRepositoryImpl implements DroneStationRepository { return Left(Failure(e.toString())); } } -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart index 447d2189..7d3c048c 100644 --- a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart +++ b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart @@ -387,34 +387,4 @@ class DroneStationEntity extends Equatable { siteId, 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 json) { - return VideoStreamEntity( - sn: json['sn'] ?? '', - cameraIndex: json['camera_index'] ?? '', - url: json['url'] ?? '', - expireTs: json['expire_ts'] ?? 0, - urlType: json['url_type'] ?? '', - ); - } - - @override - List get props => [sn, cameraIndex, url, expireTs, urlType]; -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/entities/video_stream_entity.dart b/lib/features/v2/device_list/domain/entities/video_stream_entity.dart index 7896add5..192395c0 100644 --- a/lib/features/v2/device_list/domain/entities/video_stream_entity.dart +++ b/lib/features/v2/device_list/domain/entities/video_stream_entity.dart @@ -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 parseRtcParams() { final params = {}; 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 get props => [sn, cameraIndex, url, expireTs, urlType]; diff --git a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart index c203153c..ed768bd4 100644 --- a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart @@ -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>> getDroneStationList( @@ -15,4 +16,4 @@ abstract class DroneStationRepository { required String cameraIndex, required String cameraPosition, }); -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart index faff104b..8156be55 100644 --- a/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart +++ b/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart @@ -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 { @@ -19,4 +19,4 @@ class GetVideoStreamUseCase { cameraPosition: cameraPosition, ); } -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart index 0f72821d..7a01abfa 100644 --- a/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart @@ -76,7 +76,7 @@ class DroneStationBloc extends Bloc { result.fold( (failure) => emit(VideoStreamError(failure.message)), - (videoStream) => emit(VideoStreamLoaded(videoStream)), + (videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)), ); } } diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart index a70afd2d..1b42422a 100644 --- a/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart @@ -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 get props => [videoStream]; + List get props => [videoStream, cameraPosition]; } class VideoStreamError extends DroneStationState { @@ -76,4 +78,4 @@ class VideoStreamError extends DroneStationState { @override List get props => [message]; -} +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 22b21493..b6d4dbaf 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -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}); @@ -22,9 +24,10 @@ class DeviceStatusPage extends StatelessWidget { Widget build(BuildContext context) { final selectedSite = sl().state.selectedSite; final siteId = selectedSite?.id; - + return BlocProvider( - create: (_) => sl()..add(DeviceStatusLoadData(siteId: siteId)), + create: (_) => + sl()..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().add(DeviceStatusSearch(value)); @@ -180,7 +178,9 @@ class DeviceStatusView extends StatelessWidget { return GestureDetector( onTap: () { - context.read().add(DeviceStatusChangeType(type)); + context.read().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().add(const DeviceStatusLoadData()); + context.read().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( @@ -328,24 +330,17 @@ class DeviceStatusView extends StatelessWidget { Widget _buildDroneStationList(BuildContext context) { // 从全局 SiteCubit 获取选中的场站 ID final selectedSite = sl().state.selectedSite; - + if (selectedSite == null) { return Center( 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()..add(DroneStationLoadData(selectedSite.id)), + create: (_) => + sl()..add(DroneStationLoadData(selectedSite.id)), child: BlocBuilder( 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().add(DroneStationLoadData(selectedSite.id)); + context.read().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().add(DroneStationRefresh(selectedSite.id)); + context.read().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 { ), ); } - } diff --git a/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart index 8d995b8d..3ac23ca0 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart @@ -1,10 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:volc_engine_rtc/volc_engine_rtc.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 '../bloc/drone_station_bloc.dart'; import '../bloc/drone_station_event.dart'; import '../bloc/drone_station_state.dart'; +import '../../domain/entities/video_stream_entity.dart'; + +// SDK 类型枚举 +enum RtcSdkType { volcengine, agora } + +// 视频流类型 +enum VideoStreamType { indoor, outdoor } class DroneMonitorPage extends StatefulWidget { final String gatewaySn; @@ -20,358 +28,835 @@ class DroneMonitorPage extends StatefulWidget { _DroneMonitorPageState createState() => _DroneMonitorPageState(); } -class _DroneMonitorPageState extends State { - bool isIndoor = false; +class _DroneMonitorPageState extends State + with WidgetsBindingObserver { late DroneStationBloc _bloc; - String? videoUrl; - bool isLoading = true; - String? errorMessage; - // RTCEngine? _rtcEngine; - // RTCRoom? _rtcRoom; - // RTCViewContext? _remoteRenderContext; - // final IRTCEngineEventHandler _videoHandler = IRTCEngineEventHandler(); - // final IRTCRoomEventHandler _roomHandler = IRTCRoomEventHandler(); + // 室内视频流状态 + VideoStreamEntity? _indoorVideoStream; + bool _indoorLoading = true; + String? _indoorErrorMessage; + String? _indoorRemoteUserId; + bool _indoorIsAgora = false; + volc.RTCEngine? _indoorRtcEngine; + volc.RTCRoom? _indoorRtcRoom; + volc.RTCViewContext? _indoorRemoteRenderContext; + agora.RtcEngine? _indoorAgoraEngine; - String? _appId; - String? _roomId; - String? _token; - String? _userId; - bool _isJoined = false; + // 室外视频流状态 + VideoStreamEntity? _outdoorVideoStream; + bool _outdoorLoading = true; + String? _outdoorErrorMessage; + String? _outdoorRemoteUserId; + bool _outdoorIsAgora = false; + volc.RTCEngine? _outdoorRtcEngine; + volc.RTCRoom? _outdoorRtcRoom; + volc.RTCViewContext? _outdoorRemoteRenderContext; + agora.RtcEngine? _outdoorAgoraEngine; + + // 视频卡顿检测:记录上次刷新时间 + DateTime? _lastOutdoorRefreshTime; + DateTime? _lastIndoorRefreshTime; + + // 重试计数 + int? _indoorRetryCount; + int? _outdoorRetryCount; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _bloc = sl(); - // _initVideoEventHandler(); - // _initRoomEventHandler(); - _loadVideoStream(); + _loadBothVideoStreams(); + // 启动定时检测 + _startStuckDetection(); } - // void _initVideoEventHandler() { - // _videoHandler.onFirstRemoteVideoFrameDecoded = - // (String streamId, StreamInfo streamInfo, VideoFrameInfo frameInfo) { - // debugPrint('onFirstRemoteVideoFrameDecoded: ${streamInfo.userId}'); - // if (streamInfo.userId.isNotEmpty && mounted && _roomId != null) { - // setState(() { - // _remoteRenderContext = RTCViewContext.remoteContext( - // roomId: _roomId!, - // userId: streamInfo.userId, - // streamId: streamId, - // ); - // }); - // } - // }; - - // _videoHandler.onWarning = (WarningCode code) { - // debugPrint('warningCode: $code'); - // }; - - // _videoHandler.onError = (ErrorCode code) { - // debugPrint('errorCode: $code'); - // if (mounted) { - // setState(() { - // errorMessage = '视频错误: $code'; - // isLoading = false; - // }); - // } - // }; - // } - - // void _initRoomEventHandler() { - // _roomHandler.onUserJoined = (UserInfo userInfo) { - // debugPrint('onUserJoined: ${userInfo.userId}'); - // }; - - // _roomHandler.onUserLeave = (String uid, int reason) { - // debugPrint('onUserLeave: $uid reason: $reason'); - // }; - // } - @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _bloc.close(); - _leaveRoom(); + _destroyAllRtcEngines(); super.dispose(); } - void _loadVideoStream() { - setState(() { - isLoading = true; - errorMessage = null; + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // App 从后台恢复时刷新视频 + debugPrint('App 恢复,重新加载视频流'); + _loadBothVideoStreams(); + } + } + + // 定时检测视频是否卡住 + void _startStuckDetection() { + Future.delayed(const Duration(seconds: 10), () { + if (!mounted) return; + + final now = DateTime.now(); + + // 如果室外视频超过10秒没有刷新,尝试刷新 + if (_lastOutdoorRefreshTime != null && + _outdoorRemoteUserId != null && + now.difference(_lastOutdoorRefreshTime!).inSeconds > 15) { + debugPrint('检测到室外视频卡住,尝试刷新...'); + _refreshOneVideoStream(VideoStreamType.outdoor); + } + + // 如果室内视频超过10秒没有刷新,尝试刷新 + if (_lastIndoorRefreshTime != null && + _indoorRemoteUserId != null && + now.difference(_lastIndoorRefreshTime!).inSeconds > 15) { + debugPrint('检测到室内视频卡住,尝试刷新...'); + _refreshOneVideoStream(VideoStreamType.indoor); + } + + // 继续定时检测 + if (mounted) { + _startStuckDetection(); + } }); - _leaveRoom(); + } + + // 刷新单个视频流 + void _refreshOneVideoStream(VideoStreamType type) { + _destroyRtcEngine(type); + _loadVideoStream(type); + } + + // 同时加载室内和室外视频流 - 依次加载避免状态混乱 + void _loadBothVideoStreams() async { + // 先加载室内视频流 + _loadVideoStream(VideoStreamType.indoor); + // 等待室内加载完成后再加载室外 + await Future.delayed(const Duration(milliseconds: 500)); + _loadVideoStream(VideoStreamType.outdoor); + } + + void _loadVideoStream(VideoStreamType type) { + // 更新最后刷新时间 + if (type == VideoStreamType.indoor) { + _lastIndoorRefreshTime = DateTime.now(); + } else { + _lastOutdoorRefreshTime = DateTime.now(); + } + + setState(() { + if (type == VideoStreamType.indoor) { + _indoorLoading = true; + _indoorErrorMessage = null; + } else { + _outdoorLoading = true; + _outdoorErrorMessage = null; + } + }); + + _destroyRtcEngine(type); + _bloc.add( VideoStreamLoad( sn: widget.gatewaySn, cameraIndex: widget.cameraIndex, - cameraPosition: isIndoor ? 'indoor' : 'outdoor', + // 恢复原始参数(室内请求 indoor,室外请求 outdoor) + cameraPosition: type == VideoStreamType.indoor ? 'indoor' : 'outdoor', ), ); } - void _onCameraPositionChanged(bool indoor) { - setState(() { - isIndoor = indoor; - }); - _loadVideoStream(); - } - - void _parseUrlParams(String url) { - try { - final uri = Uri.parse(url.contains('?') ? 'http://host?$url' : url); - final params = uri.queryParameters; - - _appId = params['app_id']; - _roomId = params['room_id']; - _token = params['token']; - _userId = - params['user_id'] ?? 'user_${DateTime.now().millisecondsSinceEpoch}'; - - print( - 'Parsed RTC params: appId=$_appId, roomId=$_roomId, userId=$_userId', - ); - } catch (e) { - print('Failed to parse URL: $e'); - errorMessage = '解析视频参数失败'; - isLoading = false; + void _destroyRtcEngine(VideoStreamType type) async { + if (type == VideoStreamType.indoor) { + // 销毁室内 RTC + if (_indoorRtcRoom != null) { + try { + await _indoorRtcRoom?.leaveRoom(); + } catch (_) {} + _indoorRtcRoom = null; + } + if (_indoorRtcEngine != null) { + try { + _indoorRtcEngine?.destroy(); + } catch (_) {} + _indoorRtcEngine = null; + } + if (_indoorAgoraEngine != null) { + try { + await _indoorAgoraEngine?.leaveChannel(); + } catch (_) {} + try { + _indoorAgoraEngine?.release(); + } catch (_) {} + _indoorAgoraEngine = null; + } + _indoorRemoteRenderContext = null; + _indoorRemoteUserId = null; + _indoorIsAgora = false; + } else { + // 销毁室外 RTC + if (_outdoorRtcRoom != null) { + try { + await _outdoorRtcRoom?.leaveRoom(); + } catch (_) {} + _outdoorRtcRoom = null; + } + if (_outdoorRtcEngine != null) { + try { + _outdoorRtcEngine?.destroy(); + } catch (_) {} + _outdoorRtcEngine = null; + } + if (_outdoorAgoraEngine != null) { + try { + await _outdoorAgoraEngine?.leaveChannel(); + } catch (_) {} + try { + _outdoorAgoraEngine?.release(); + } catch (_) {} + _outdoorAgoraEngine = null; + } + _outdoorRemoteRenderContext = null; + _outdoorRemoteUserId = null; + _outdoorIsAgora = false; } } - // Future _joinRoom() async { - // if (_appId == null || _roomId == null || _token == null) { - // return; - // } + void _destroyAllRtcEngines() async { + _destroyRtcEngine(VideoStreamType.indoor); + _destroyRtcEngine(VideoStreamType.outdoor); + } - // try { - // _rtcEngine = await RTCEngine.createRTCEngine( - // RTCVideoContext(appId: _appId!, eventHandler: _videoHandler), - // ); + Future _initRtcEngine( + VideoStreamType type, + VideoStreamEntity? stream, + ) async { + if (stream == null) return; - // if (_rtcEngine == null) { - // setState(() { - // errorMessage = '创建引擎失败'; - // isLoading = false; - // }); - // return; - // } + final appId = stream.appId; + final roomId = stream.roomId; + final token = stream.token; - // _rtcRoom = await _rtcEngine?.createRTCRoom(_roomId!); - // _rtcRoom?.setRTCRoomEventHandler(_roomHandler); + // 使用服务器返回的 userId 作为 uid + int uid; + if (stream.userId.isNotEmpty) { + uid = int.tryParse(stream.userId) ?? 0; + } else { + uid = 0; + } - // final userInfo = UserInfo(userId: _userId!, extraInfo: ""); + debugPrint('${type.name} 使用的 uid: $uid'); - // final roomConfig = RoomConfig( - // isPublishAudio: false, - // isPublishVideo: false, - // isAutoSubscribeAudio: true, - // isAutoSubscribeVideo: true, - // ); - - // await _rtcRoom?.joinRoom( - // token: _token!, - // userInfo: userInfo, - // roomConfig: roomConfig, - // userVisibility: true, - // ); - - // setState(() { - // _isJoined = true; - // }); - - // print('Successfully joined room: $_roomId'); - // } catch (e) { - // print('Failed to join room: $e'); - // setState(() { - // errorMessage = '加入房间失败: $e'; - // isLoading = false; - // }); - // } - // } - - Future _leaveRoom() async { - try { - // if (_rtcRoom != null) { - // await _rtcRoom!.leaveRoom(); - // _rtcRoom = null; - // } - // if (_rtcEngine != null) { - // _rtcEngine!.destroy(); - // _rtcEngine = null; - // } + if (appId.isEmpty || roomId.isEmpty || token.isEmpty) { setState(() { - _isJoined = false; + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'RTC 参数缺失'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'RTC 参数缺失'; + _outdoorLoading = false; + } }); + return; + } + + debugPrint('=== ${type.name} RTC 初始化 ==='); + debugPrint('AppId: $appId'); + debugPrint('RoomId: $roomId'); + debugPrint('Uid: $uid'); + debugPrint('URL Type: ${stream.urlType}'); + + final sdkType = stream.urlType.toLowerCase() == 'agora' + ? RtcSdkType.agora + : RtcSdkType.volcengine; + + if (sdkType == RtcSdkType.agora) { + await _initAgoraEngine(type, appId, roomId, token, uid); + } else { + await _initVolcEngine(type, appId, roomId, token, uid.toString()); + } + } + + // Agora SDK 初始化 + Future _initAgoraEngine( + VideoStreamType type, + String appId, + String channelId, + String token, + int uid, + ) async { + try { + debugPrint('=== ${type.name} Agora RTC 初始化 ==='); + debugPrint('AppId: "$appId" (长度: ${appId.length})'); + debugPrint('RoomId: "$channelId"'); + debugPrint('Uid: $uid'); + + // 参数验证 + if (appId.isEmpty) { + throw Exception('AppId 为空'); + } + + final engine = agora.createAgoraRtcEngine(); + + // 添加 try-catch 捕获初始化异常 + try { + await engine.initialize(agora.RtcEngineContext(appId: appId)); + } catch (initError) { + debugPrint('❌ ${type.name} Agora 引擎初始化失败: $initError'); + throw initError; + } + + engine.enableVideo(); + debugPrint('${type.name} Agora 引擎初始化成功'); + + engine.registerEventHandler( + agora.RtcEngineEventHandler( + onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) { + debugPrint('✅ ${type.name} Agora 加入频道成功: ${connection.channelId}'); + }, + onUserJoined: (agora.RtcConnection connection, int uid, int elapsed) { + debugPrint('✅ ${type.name} Agora 用户加入: uid=$uid'); + setState(() { + if (type == VideoStreamType.indoor) { + _indoorRemoteUserId = uid.toString(); + _indoorIsAgora = true; + _indoorLoading = false; + _indoorAgoraEngine = engine; + } else { + _outdoorRemoteUserId = uid.toString(); + _outdoorIsAgora = true; + _outdoorLoading = false; + _outdoorAgoraEngine = engine; + } + }); + }, + onUserOffline: + ( + agora.RtcConnection connection, + int uid, + agora.UserOfflineReasonType reason, + ) { + debugPrint('${type.name} Agora 用户离开: $uid'); + setState(() { + if (type == VideoStreamType.indoor && + uid.toString() == _indoorRemoteUserId) { + _indoorRemoteUserId = null; + } else if (type == VideoStreamType.outdoor && + uid.toString() == _outdoorRemoteUserId) { + _outdoorRemoteUserId = null; + } + }); + }, + onError: (agora.ErrorCodeType err, String msg) { + debugPrint('❌ ${type.name} Agora 错误: $err - $msg'); + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Agora RTC 错误:$err'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Agora RTC 错误:$err'; + _outdoorLoading = false; + } + }); + }, + ), + ); + + // 设置视频配置(优化流畅度)- 使用默认配置,避免SDK版本兼容问题 + try { + await engine.setVideoEncoderConfiguration( + agora.VideoEncoderConfiguration(), + ); + debugPrint('${type.name} Agora 视频配置已设置'); + } catch (e) { + debugPrint('${type.name} Agora 视频配置设置失败: $e'); + } + + debugPrint('${type.name} 准备加入频道: channelId=$channelId, uid=$uid'); + + // 简化 joinChannel 参数,避免无效参数问题 + try { + // 使用服务器返回的 userId 作为 uid + await engine.joinChannel( + token: token, + channelId: channelId, + uid: uid, + options: agora.ChannelMediaOptions( + channelProfile: + agora.ChannelProfileType.channelProfileLiveBroadcasting, + clientRoleType: agora.ClientRoleType.clientRoleAudience, + autoSubscribeVideo: true, + autoSubscribeAudio: false, + ), + ); + debugPrint('✅ ${type.name} Agora joinChannel 调用成功'); + } catch (e) { + debugPrint('❌ ${type.name} Agora joinChannel 失败: $e'); + + // 记录重试次数 + if (type == VideoStreamType.indoor) { + _indoorRetryCount = (_indoorRetryCount ?? 0) + 1; + } else { + _outdoorRetryCount = (_outdoorRetryCount ?? 0) + 1; + } + + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = '视频连接失败: $e'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = '视频连接失败: $e'; + _outdoorLoading = false; + } + }); + + // 自动重试(最多3次) + final retryCount = type == VideoStreamType.indoor + ? _indoorRetryCount + : _outdoorRetryCount; + if (retryCount! < 3) { + debugPrint('${type.name} 第 $retryCount 次重试...'); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + _loadVideoStream(type); + } + }); + } + + return; + } + + if (type == VideoStreamType.indoor) { + _indoorAgoraEngine = engine; + } else { + _outdoorAgoraEngine = engine; + } } catch (e) { - print('Failed to leave room: $e'); + debugPrint('❌ ${type.name} Agora RTC 初始化失败: $e'); + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Agora RTC 初始化失败:$e'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Agora RTC 初始化失败:$e'; + _outdoorLoading = false; + } + }); + } + } + + // 火山引擎 SDK 初始化 + Future _initVolcEngine( + VideoStreamType type, + String appId, + String roomId, + String token, + String userId, + ) async { + try { + debugPrint('=== ${type.name} VolcEngine RTC 初始化 ==='); + + final engineEventHandler = volc.IRTCEngineEventHandler( + onWarning: (volc.WarningCode code) { + debugPrint('${type.name} Volc Warning: $code'); + }, + onError: (volc.ErrorCode code) { + debugPrint('${type.name} Volc Error: $code'); + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Volc RTC 错误:$code'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Volc RTC 错误:$code'; + _outdoorLoading = false; + } + }); + }, + ); + + final engine = await volc.RTCEngine.createRTCEngine( + volc.RTCVideoContext(appId: appId, eventHandler: engineEventHandler), + ); + + if (engine == null) { + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Volc RTC 引擎创建失败'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Volc RTC 引擎创建失败'; + _outdoorLoading = false; + } + }); + return; + } + + final room = await engine.createRTCRoom(roomId); + + if (room == null) { + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Volc RTC 房间创建失败'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Volc RTC 房间创建失败'; + _outdoorLoading = false; + } + }); + return; + } + + final roomEventHandler = volc.IRTCRoomEventHandler( + onUserPublishStreamVideo: + (String userId, volc.StreamInfo streamInfo, bool isPublish) { + debugPrint('${type.name} Volc 远端用户 $userId 视频流状态: $isPublish'); + setState(() { + if (isPublish) { + if (type == VideoStreamType.indoor) { + _indoorRemoteUserId = userId; + _indoorIsAgora = false; + _indoorRemoteRenderContext = + volc.RTCViewContext.remoteContext( + roomId: roomId, + userId: userId, + ); + _indoorLoading = false; + } else { + _outdoorRemoteUserId = userId; + _outdoorIsAgora = false; + _outdoorRemoteRenderContext = + volc.RTCViewContext.remoteContext( + roomId: roomId, + userId: userId, + ); + _outdoorLoading = false; + } + } else { + if (type == VideoStreamType.indoor && + userId == _indoorRemoteUserId) { + _indoorRemoteRenderContext = null; + _indoorRemoteUserId = null; + } else if (type == VideoStreamType.outdoor && + userId == _outdoorRemoteUserId) { + _outdoorRemoteRenderContext = null; + _outdoorRemoteUserId = null; + } + } + }); + }, + onUserLeave: (String userId, int reason) { + debugPrint('${type.name} Volc 用户离开: $userId'); + setState(() { + if (type == VideoStreamType.indoor && + userId == _indoorRemoteUserId) { + _indoorRemoteRenderContext = null; + _indoorRemoteUserId = null; + } else if (type == VideoStreamType.outdoor && + userId == _outdoorRemoteUserId) { + _outdoorRemoteRenderContext = null; + _outdoorRemoteUserId = null; + } + }); + }, + ); + + await room.setRTCRoomEventHandler(roomEventHandler); + + await room.joinRoom( + token: token, + userInfo: volc.UserInfo(userId: userId, extraInfo: ''), + userVisibility: true, + roomConfig: volc.RoomConfig( + isPublishAudio: false, + isPublishVideo: false, + isAutoSubscribeAudio: false, + isAutoSubscribeVideo: true, + ), + ); + + if (type == VideoStreamType.indoor) { + _indoorRtcEngine = engine; + _indoorRtcRoom = room; + } else { + _outdoorRtcEngine = engine; + _outdoorRtcRoom = room; + } + } catch (e) { + debugPrint('❌ ${type.name} Volc RTC 初始化失败: $e'); + setState(() { + if (type == VideoStreamType.indoor) { + _indoorErrorMessage = 'Volc RTC 初始化失败:$e'; + _indoorLoading = false; + } else { + _outdoorErrorMessage = 'Volc RTC 初始化失败:$e'; + _outdoorLoading = false; + } + }); } } @override Widget build(BuildContext context) { - return BlocProvider.value( - value: _bloc, - child: Scaffold( - backgroundColor: const Color(0xFF1D2129), - appBar: AppBar( - backgroundColor: const Color(0xFF2A2E34), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - title: const Text('实时监控', style: TextStyle(color: Colors.white)), + return Scaffold( + appBar: AppBar( + title: const Text('无人机监控'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + _destroyAllRtcEngines(); + Navigator.pop(context); + }, ), - body: Column( - children: [ - Expanded( - child: BlocConsumer( - listener: (context, state) async { - if (state is VideoStreamLoaded) { - videoUrl = state.videoStream.url; - _parseUrlParams(videoUrl!); - // await _joinRoom(); - if (mounted) { - setState(() { - isLoading = false; - }); - } - } else if (state is VideoStreamError) { - if (mounted) { - setState(() { - isLoading = false; - errorMessage = state.message; - }); - } - } - }, - builder: (context, state) { - if (isLoading) { - return const Center(child: CircularProgressIndicator()); - } + ), + body: BlocConsumer( + bloc: _bloc, + listener: (context, state) { + if (state is VideoStreamLoaded) { + // 根据加载的摄像头位置判断是室内还是室外 + final position = state.cameraPosition; + final type = position == 'indoor' + ? VideoStreamType.indoor + : VideoStreamType.outdoor; - if (errorMessage != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error, size: 64, color: Colors.red), - const SizedBox(height: 16), - Text( - errorMessage!, - style: const TextStyle(color: Colors.red), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadVideoStream, - child: const Text('重试'), - ), - ], - ), - ); - } + // 打印详细的视频流参数,方便排查问题 + debugPrint('=== ${position} 视频流参数 ==='); + debugPrint('AppId: ${state.videoStream.appId}'); + debugPrint('RoomId: ${state.videoStream.roomId}'); + debugPrint('UserId: ${state.videoStream.userId}'); + debugPrint('Token: ${state.videoStream.token.substring(0, 20)}...'); + debugPrint('URL Type: ${state.videoStream.urlType}'); - return _buildVideoView(); - }, + setState(() { + if (type == VideoStreamType.indoor) { + _indoorVideoStream = state.videoStream; + } else { + _outdoorVideoStream = state.videoStream; + } + }); + + _initRtcEngine(type, state.videoStream); + } else if (state is VideoStreamError) { + // 根据错误信息判断是哪个视频流 + setState(() { + // 简化处理:两个都显示错误 + if (_indoorLoading) { + _indoorErrorMessage = state.message; + _indoorLoading = false; + } + if (_outdoorLoading) { + _outdoorErrorMessage = state.message; + _outdoorLoading = false; + } + }); + } + }, + builder: (context, state) { + return Column( + children: [ + // 上下堆叠显示两个视频 + // 第一个视频(室内)- 占45%高度 + Expanded( + flex: 9, + child: _buildVideoCard( + title: '室内', + type: VideoStreamType.indoor, + isLoading: _indoorLoading, + errorMessage: _indoorErrorMessage, + remoteUserId: _indoorRemoteUserId, + isAgora: _indoorIsAgora, + agoraEngine: _indoorAgoraEngine, + renderContext: _indoorRemoteRenderContext, + videoStream: _indoorVideoStream, + ), ), - ), - Container( - padding: const EdgeInsets.all(16), - color: const Color(0xFF2A2E34), - child: Row( - children: [ - Expanded( - child: _buildBtn( - Icons.home, - '室内', - isIndoor, - () => _onCameraPositionChanged(true), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildBtn( - Icons.sunny, - '室外', - !isIndoor, - () => _onCameraPositionChanged(false), - ), - ), - ], + // 分隔线 + Container(height: 1, color: Colors.grey.withOpacity(0.3)), + // 第二个视频(室外)- 占45%高度 + Expanded( + flex: 9, + child: _buildVideoCard( + title: '室外', + type: VideoStreamType.outdoor, + isLoading: _outdoorLoading, + errorMessage: _outdoorErrorMessage, + remoteUserId: _outdoorRemoteUserId, + isAgora: _outdoorIsAgora, + agoraEngine: _outdoorAgoraEngine, + renderContext: _outdoorRemoteRenderContext, + videoStream: _outdoorVideoStream, + ), ), - ), - ], - ), + // 刷新按钮区域 - 占10%高度 + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: _loadBothVideoStreams, + icon: const Icon(Icons.refresh), + label: const Text('刷新视频流'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + ), + ), + ), + ], + ); + }, ), ); } - Widget _buildVideoView() { - // if (!_isJoined || _rtcEngine == null || _userId == null) { - // return Center( - // child: Column( - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // const Icon(Icons.video_library, size: 80, color: Colors.grey), - // const SizedBox(height: 16), - // const Text( - // '正在连接...', - // style: TextStyle(color: Colors.white, fontSize: 16), - // ), - // const SizedBox(height: 8), - // Text( - // '房间: $_roomId', - // style: const TextStyle(color: Colors.grey, fontSize: 12), - // ), - // ], - // ), - // ); - // } - + // 构建单个视频卡片 + Widget _buildVideoCard({ + required String title, + required VideoStreamType type, + required bool isLoading, + required String? errorMessage, + required String? remoteUserId, + required bool isAgora, + required agora.RtcEngine? agoraEngine, + required volc.RTCViewContext? renderContext, + required VideoStreamEntity? videoStream, + }) { return Container( color: Colors.black, - child: Center( + child: Column( + children: [ + // 标题栏 + Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + color: Colors.black.withOpacity(0.7), + child: Row( + children: [ + Icon( + type == VideoStreamType.indoor + ? Icons.home + : Icons.outdoor_grill, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (isLoading) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + if (errorMessage != null) + Icon(Icons.error_outline, color: Colors.red, size: 16), + if (remoteUserId != null) + Icon(Icons.video_camera_front, color: Colors.green, size: 16), + ], + ), + ), + // 视频区域 - 使用固定比例确保视频完整展示 + Expanded( + child: _buildVideoContent( + isLoading: isLoading, + errorMessage: errorMessage, + remoteUserId: remoteUserId, + isAgora: isAgora, + agoraEngine: agoraEngine, + renderContext: renderContext, + videoStream: videoStream, + ), + ), + ], + ), + ); + } + + // 构建视频内容 + Widget _buildVideoContent({ + required bool isLoading, + required String? errorMessage, + required String? remoteUserId, + required bool isAgora, + required agora.RtcEngine? agoraEngine, + required volc.RTCViewContext? renderContext, + required VideoStreamEntity? videoStream, + }) { + if (isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ); + } + + if (errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 32, color: Colors.red), + const SizedBox(height: 8), + Text( + errorMessage, + style: const TextStyle(color: Colors.red, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + if (remoteUserId == null) { + return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.videocam_off, size: 80, color: Colors.grey), - const SizedBox(height: 16), - const Text( - 'RTC 功能已禁用', - style: TextStyle(color: Colors.white, fontSize: 16), - ), - const SizedBox(height: 8), + Icon(Icons.video_camera_front, size: 32, color: Colors.grey), + SizedBox(height: 8), Text( - '火山引擎 RTC 依赖已注释', - style: const TextStyle(color: Colors.grey, fontSize: 12), + '等待视频流...', + style: TextStyle(color: Colors.grey, fontSize: 12), ), ], ), - ), - ); - } + ); + } - Widget _buildBtn( - IconData icon, - String label, - bool active, - VoidCallback onTap, - ) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: active ? const Color(0xFF165DFF) : const Color(0xFF3A3E44), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Icon(icon, color: Colors.white), - const SizedBox(height: 8), - Text(label, style: const TextStyle(color: Colors.white)), - ], - ), - ), - ); + // 让视频完整显示(使用 fit 模式,可能有黑边但内容完整) + return isAgora && agoraEngine != null + ? SizedBox.expand( + child: agora.AgoraVideoView( + controller: agora.VideoViewController.remote( + rtcEngine: agoraEngine, + canvas: agora.VideoCanvas( + uid: int.parse(remoteUserId), + renderMode: + agora.RenderModeType.renderModeFit, // 使用 Fit 模式确保内容完整 + ), + connection: agora.RtcConnection( + channelId: videoStream?.roomId ?? '', + ), + ), + ), + ) + : renderContext != null + ? SizedBox.expand( + child: volc.RTCSurfaceView( + context: renderContext, + renderMode: volc.VideoRenderMode.fit, // 使用 fit 模式确保内容完整 + ), + ) + : const Center( + child: Text( + '视频初始化中...', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ); } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index 0e09a986..507a2991 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -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 { 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 { @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 { ), centerTitle: true, ), - body: BlocBuilder( + body: BlocConsumer( + 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 { 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 { GestureDetector( onTap: () { Navigator.pop(context); - setState(() => showFloatingMonitor = true); + _loadFloatingVideoStream(); }, child: Container( padding: const EdgeInsets.all(16), @@ -639,7 +714,10 @@ class _DroneStationDetailPageState extends State { 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 { ), // 关闭按钮 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 { ); } + // 加载悬浮视频流 + 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 _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 _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 _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 cameras) { String value = cameras .map((c) => '${c.cameraIndex}:${c.cameraPosition}') diff --git a/lib/features/v2/device_list/presentation/pages/robot_control_page.dart b/lib/features/v2/device_list/presentation/pages/robot_control_page.dart index a132a439..c511e570 100644 --- a/lib/features/v2/device_list/presentation/pages/robot_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/robot_control_page.dart @@ -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), diff --git a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart index 148c9964..d9d61e2e 100644 --- a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart +++ b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart @@ -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 { battery: robot.battery, task: robot.task, onTap: () { - // 1. 将当前机器人设置为全局选中设备 + // final logger = sl(); + 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 { onlineStatus: robot.status == '在线' ? 1 : 0, ); - context.read().selectDevice(device); + // 🔥 使用 GetIt 直接获取 RemoteControlCubit 单例 + final remoteCubit = GetIt.I(); + remoteCubit.setTargetDevice(device); // 2. 跳转到机器人控制页面 final robotMap = { diff --git a/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart b/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart index 7f174a1d..c3a67091 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_control_panel.dart @@ -18,7 +18,7 @@ class RobotControlPanel extends StatelessWidget { onTap: () { debugPrint('👆 控制面板被点击'); debugPrint('📋 robot 数据: $robot'); - + // 1. 将当前机器人设置为全局选中设备 final device = DeviceEntity( deviceName: robot['name']?.toString() ?? '', @@ -29,146 +29,139 @@ class RobotControlPanel extends StatelessWidget { status: robot['status'] == '在线' ? 1 : 0, onlineStatus: robot['status'] == '在线' ? 1 : 0, ); - + debugPrint('✅ 设置选中设备: ${device.deviceName}'); context.read().selectDevice(device); - - // 打印当前选中的设备信息 - final selectedDevice = context.read().state.selectedDevice; - debugPrint('📱 当前选中设备信息:'); - debugPrint(' - deviceName: ${selectedDevice?.deviceName}'); - debugPrint(' - productName: ${selectedDevice?.productName}'); - debugPrint(' - status: ${selectedDevice?.status}'); - debugPrint(' - onlineStatus: ${selectedDevice?.onlineStatus}'); - + + // 🔥 同时设置为全局待控制设备 + final remoteCubit = sl(); + remoteCubit.setTargetDevice(device); + debugPrint('🎯 已设置 targetDevice: ${device.deviceName}'); + + // 🔥 在跳转前获取 DevicesCubit,避免在 builder 内部 context 找不到 + final devicesCubit = context.read(); + // 2. 跳转到远程遥控页面 debugPrint('🚀 准备跳转...'); Navigator.push( context, MaterialPageRoute( - builder: (context) => BlocProvider( - create: (_) => sl()..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, () {})), + ], + ), + ], + ), ), ); } diff --git a/lib/features/v2/home/data/datasources/site_datasource_impl.dart b/lib/features/v2/home/data/datasources/site_datasource_impl.dart index 06f3e18d..a87cfee9 100644 --- a/lib/features/v2/home/data/datasources/site_datasource_impl.dart +++ b/lib/features/v2/home/data/datasources/site_datasource_impl.dart @@ -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> 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 = { '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) { diff --git a/lib/features/v2/home/presentation/pages/home_v2_page.dart b/lib/features/v2/home/presentation/pages/home_v2_page.dart index 1a670892..f86ae1d9 100644 --- a/lib/features/v2/home/presentation/pages/home_v2_page.dart +++ b/lib/features/v2/home/presentation/pages/home_v2_page.dart @@ -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 { ), ), ), - 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 { 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()); + } + }); } } diff --git a/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart b/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart new file mode 100644 index 00000000..22696148 --- /dev/null +++ b/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart @@ -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 createState() => _TcpStatusIndicatorState(); +} + +class _TcpStatusIndicatorState extends State + with SingleTickerProviderStateMixin { + late TcpStatusCubit _tcpStatusCubit; + bool _hasActivity = false; + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _tcpStatusCubit = GetIt.I(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(reverse: true); + + _animation = Tween(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, + ), + ] + : [], + ), + ); + }, + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 660ec151..22f48dcc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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().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().checkUpdate(), + // // ), + // ], + // ), + // ), + // ), + // ), ], ); }, diff --git a/pubspec.lock b/pubspec.lock index d4d5ee41..c3347c06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index fdc6857b..e8be34cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # ===== 屏幕适配,高刷等 =====