diff --git a/lib/core/bluetooth/ble_manager.dart b/lib/core/bluetooth/ble_manager.dart index 25553972..0c558039 100644 --- a/lib/core/bluetooth/ble_manager.dart +++ b/lib/core/bluetooth/ble_manager.dart @@ -15,6 +15,9 @@ class BleManager { BluetoothCharacteristic? _writeCharacteristic; BluetoothCharacteristic? _readCharacteristic; + /// 🔥 已连接设备的名称(连接时从扫描结果或设备属性保存,断开时清空) + String? _connectedDeviceName; + /// 有状态的协议解析器(支持 BLE 分片) final ProtocolParser _parser = ProtocolParser(); @@ -50,6 +53,15 @@ class BleManager { bool get isConnected => _connectedDevice != null; BluetoothDevice? get connectedDevice => _connectedDevice; BluetoothDevice? get connectingDevice => _connectingDevice; + + /// 🔥 获取已连接设备的名称(连接时缓存,断开后为 null) + String? get connectedDeviceName => _connectedDeviceName; + + /// 🔥 获取当前蓝牙扫描结果列表(供外部查询使用) + List getScanResults() { + return _scanResults.values.toList(); + } + Stream> get scanResults => _scanController.stream; Stream get packetStream => _packetController.stream; Stream get adapterState => @@ -238,6 +250,17 @@ class BleManager { /// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败 Future connect(BluetoothDevice device) async { + // 🔥 连接前先保存设备名(从扫描结果或设备属性中获取) + final scanResult = _scanResults[device.remoteId]; + final advName = scanResult?.device.advName ?? ''; + final platformName = device.platformName; + _connectedDeviceName = platformName.isNotEmpty + ? platformName + : (advName.isNotEmpty ? advName : device.remoteId.toString()); + developer.log( + '[BLE] 🔥 保存已连接设备名: $_connectedDeviceName', + name: 'BleManager', + ); // 标记正在连接,通知所有监听者 _connectingDevice = device; _connectingController.add(device); @@ -288,6 +311,7 @@ class BleManager { void _onDeviceDisconnected() { _connectedDevice = null; _connectingDevice = null; + _connectedDeviceName = null; // 🔥 清空已连接设备名 _writeCharacteristic = null; _readCharacteristic = null; _negotiatedMtu = 23; diff --git a/lib/features/main_container/domain/tab_config.dart b/lib/features/main_container/domain/tab_config.dart index c002cda6..eb23f530 100644 --- a/lib/features/main_container/domain/tab_config.dart +++ b/lib/features/main_container/domain/tab_config.dart @@ -44,12 +44,47 @@ class TabConfig extends Equatable { const TabConfig({required this.items}); + /// 个人角色标识(登录接口返回的 roleKey) + static const String personalRoleKey = 'personal'; + + /// 个人角色可见的 Tab ID 集合(单一数据源) + static const Set personalVisibleTabIds = {'device', 'me'}; + List get enabledItems { final enabled = items.where((item) => item.isEnabled).toList(); enabled.sort((a, b) => a.order.compareTo(b.order)); return enabled; } + /// 底部导航栏渲染用: + /// - personal:强制只返回 device + me(忽略 isEnabled,恒启用),按 order 排序 + /// - 其他角色:返回用户自己启用的 Tab + List navItemsForRole(String? roleKey) { + if (roleKey == personalRoleKey) { + final forced = items + .where((item) => personalVisibleTabIds.contains(item.id)) + .map((item) => item.copyWith(isEnabled: true)) + .toList(); + forced.sort((a, b) => a.order.compareTo(b.order)); + return forced; + } + return enabledItems; + } + + /// 「Tab 设置」列表用: + /// - personal:只列出 device + me + /// - 其他角色:列出全部 + List settingItemsForRole(String? roleKey) { + if (roleKey == personalRoleKey) { + final list = items + .where((item) => personalVisibleTabIds.contains(item.id)) + .toList(); + list.sort((a, b) => a.order.compareTo(b.order)); + return list; + } + return items; + } + TabConfig copyWith({List? items}) { return TabConfig(items: items ?? this.items); } diff --git a/lib/features/main_container/presentation/main_wrapper.dart b/lib/features/main_container/presentation/main_wrapper.dart index 9f21abfb..f9eb2922 100644 --- a/lib/features/main_container/presentation/main_wrapper.dart +++ b/lib/features/main_container/presentation/main_wrapper.dart @@ -19,6 +19,7 @@ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; class MainWrapper extends StatefulWidget { const MainWrapper({super.key}); @@ -29,9 +30,14 @@ class MainWrapper extends StatefulWidget { class _MainWrapperState extends State { int _currentIndex = 0; - final PageController _pageController = PageController(); - final List _tabKeys = []; - double? _circleLeft; + + /// 上一次渲染的 Tab 数量:用于检测 Tab 集合变化(角色过滤/增删/热重载), + /// 变化时重置索引,避免停留在越界页导致 body 白屏。 + int _lastItemCount = -1; + + /// 底部导航:单个 Tab 的期望宽度与圆形指示器直径 + static const double _kTabWidth = 88; + static const double _kIndicatorSize = 44; @override void initState() { @@ -39,38 +45,12 @@ class _MainWrapperState extends State { // 🔥 确保 FloatBarSettingService 已初始化 GetIt.I(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); - } - - void _updateCirclePosition() { - if (_tabKeys.isEmpty || _currentIndex >= _tabKeys.length) return; - - final key = _tabKeys[_currentIndex]; - final renderBox = key.currentContext?.findRenderObject() as RenderBox?; - if (renderBox != null) { - setState(() { - _circleLeft = renderBox.localToGlobal(Offset.zero).dx; - }); - } - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); } void _onTabChanged(int index) { setState(() { _currentIndex = index; }); - _pageController.jumpToPage(index); - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); } IconData _getIconData(String iconName) { @@ -96,24 +76,6 @@ class _MainWrapperState extends State { } } - double _getIndicatorPosition(int itemCount, int currentIndex) { - final screenWidth = MediaQuery.of(context).size.width; - final itemWidth = screenWidth / itemCount; - return currentIndex * itemWidth; - } - - double _getCircularLeftPosition(int itemCount, int currentIndex) { - final screenWidth = MediaQuery.of(context).size.width; - final itemWidth = screenWidth / itemCount; - // 圆形应该在每个Tab项的中心 - final tabCenterX = 12 + (currentIndex * itemWidth) + (itemWidth / 2); - final circularLeft = tabCenterX - 22; // 22 = 44/2,让圆形中心对齐Tab中心 - - // 边界限制 - final maxLeft = screenWidth - 12 - 44; - return circularLeft.clamp(12.0, maxLeft); - } - Widget _buildCurrentPage(TabConfigItem tab) { switch (tab.id) { case 'home_v2': @@ -147,6 +109,7 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { + final roleKey = context.watch().state.user?.roleKey; return BlocBuilder( builder: (context, state) { if (state is! TabConfigLoaded) { @@ -156,39 +119,32 @@ class _MainWrapperState extends State { } final config = state.config; - final enabledItems = config.enabledItems; + final enabledItems = config.navItemsForRole(roleKey); if (enabledItems.isEmpty) { return const Scaffold(body: Center(child: Text('请至少启用一个 Tab'))); } - if (_currentIndex >= enabledItems.length) { + // Tab 集合变化(角色过滤 / 切换账号 / 增删 Tab / 热重载)时子页数量随之改变。 + // 用 IndexedStack 承载:不依赖 ScrollController / 滚动偏移, + // 数量变化时只会按 index 取页,绝不会出现"控制器越界 → body 白屏"。 + final itemCount = enabledItems.length; + if (_lastItemCount != itemCount) { + _lastItemCount = itemCount; _currentIndex = 0; } - - // 初始化 GlobalKey - if (_tabKeys.length != enabledItems.length) { - _tabKeys.clear(); - for (int i = 0; i < enabledItems.length; i++) { - _tabKeys.add(GlobalKey()); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); + if (_currentIndex >= itemCount) { + _currentIndex = 0; } return Scaffold( extendBody: false, + // 底部导航预留区(SafeArea 那条)与页面同色,消除突兀的底色带 + backgroundColor: context.appColors.pageBackground, body: Stack( children: [ - PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - onPageChanged: (index) { - setState(() { - _currentIndex = index; - }); - }, + IndexedStack( + index: _currentIndex, children: enabledItems .map((tab) => _buildCurrentPage(tab)) .toList(), @@ -205,102 +161,115 @@ class _MainWrapperState extends State { ), ], ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: context.appColors.cardBackground, - ), - child: SafeArea( - top: false, + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: LayoutBuilder( builder: (context, constraints) { - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 16, - ), - padding: const EdgeInsets.symmetric(vertical: 2), - decoration: BoxDecoration( - color: context.appColors.cardBackground, - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: context.appColors.divider, - width: 1, + final count = enabledItems.length; + // 预留胶囊左右内边距(6+6);Tab 少时收紧居中,Tab 多时最多铺满可用宽度 + final available = constraints.maxWidth - 12; + final desired = count * _kTabWidth; + final contentWidth = desired > available ? available : desired; + final tabWidth = contentWidth / count; + + return Align( + alignment: Alignment.center, + // 关键:bottomNavigationBar 在部分布局下会拿到「有界高度」, + // Align 默认会撑满该高度 → 胶囊被垂直居中到屏幕中部、body 被 + // 压到顶部几乎不可见(“内容压到上边了”)。heightFactor:1.0 强制 + // Align 垂直方向按子组件高度收缩,底栏只占自身高度、稳居底部。 + heightFactor: 1.0, + child: Container( + width: contentWidth + 12, + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - boxShadow: [ - BoxShadow( - color: context.appColors.cardShadow, - blurRadius: 10, - offset: const Offset(0, 2), + decoration: BoxDecoration( + color: context.appColors.cardBackground, + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: context.appColors.divider, + width: 1, ), - ], - ), - child: Stack( - children: [ - // 滑动圆形背景 - if (_circleLeft != null) + boxShadow: [ + BoxShadow( + color: context.appColors.cardShadow, + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + // 滑动圆形指示器(纯数学定位,杜绝错位/抖动) AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - left: _circleLeft! - 8, // 减去外层 margin + 向右偏移 + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + left: + _currentIndex * tabWidth + + (tabWidth - _kIndicatorSize) / 2, top: 6, child: Container( - width: 44, - height: 44, + width: _kIndicatorSize, + height: _kIndicatorSize, decoration: BoxDecoration( - color: context.appColors.divider, - shape: BoxShape.circle, - border: Border.all( - color: context.appColors.divider, - width: 1, + color: context.appColors.primary.withOpacity( + 0.12, ), + shape: BoxShape.circle, ), ), ), - // Tab 项 - Row( - children: enabledItems.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final isSelected = _currentIndex == index; + // Tab 项(用 Expanded 弹性平分,避免窄屏下固定宽度溢出) + Row( + children: enabledItems.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = _currentIndex == index; - return Expanded( - key: _tabKeys[index], - child: InkWell( - onTap: () => _onTabChanged(index), - child: SizedBox( - height: 56, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _getIconData(item.icon), - size: 24, - color: isSelected - ? context.appColors.textPrimary - : context.appColors.textTertiary, - ), - const SizedBox(height: 4), - Text( - item.name, - style: TextStyle( - fontSize: 11, + return Expanded( + child: InkWell( + onTap: () => _onTabChanged(index), + borderRadius: BorderRadius.circular(20), + child: SizedBox( + height: 56, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + _getIconData(item.icon), + size: 24, color: isSelected - ? context.appColors.textPrimary + ? context.appColors.primary : context.appColors.textTertiary, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, ), - ), - ], + const SizedBox(height: 4), + Text( + item.name, + style: TextStyle( + fontSize: 11, + color: isSelected + ? context.appColors.primary + : context.appColors.textTertiary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ], + ), ), ), - ), - ); - }).toList(), - ), - ], + ); + }).toList(), + ), + ], + ), ), ); }, diff --git a/lib/features/v2/device_list/data/models/robot_data_model.dart b/lib/features/v2/device_list/data/models/robot_data_model.dart index ed19e06d..1c53cd98 100644 --- a/lib/features/v2/device_list/data/models/robot_data_model.dart +++ b/lib/features/v2/device_list/data/models/robot_data_model.dart @@ -9,6 +9,7 @@ class RobotDataModel { final int statusCode; // 接口原始 status 状态码 final double battery; final String task; + final bool hasHost; const RobotDataModel({ required this.name, @@ -20,6 +21,7 @@ class RobotDataModel { this.statusCode = 0, required this.battery, required this.task, + this.hasHost = false, }); /// 从 JSON 创建数据模型 @@ -46,6 +48,7 @@ class RobotDataModel { statusCode: statusCode is int ? statusCode : 0, battery: batteryValue, task: json['task'] ?? json['currentTask'] ?? '待机中', + hasHost: json['hasHost'] as bool? ?? false, ); } diff --git a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart index d8c32772..eb97b52f 100644 --- a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart @@ -52,6 +52,14 @@ class _BleDeviceDetailPageState extends State { super.initState(); _initConnectionListener(); _syncInitialState(); + + // 🔥 页面加载完成后自动连接设备(如果尚未连接且未在连接中) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_isConnected && !_isConnecting) { + _connect(); + } + }); } /// 监听 BleManager 的全局连接状态流 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 f59f3bab..8c4ec1c9 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 @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../../core/di/injection.dart'; import '../../../../../core/app/app_user_cubit.dart'; @@ -10,6 +11,7 @@ import '../../../../../components/tcp_status_indicator.dart'; import '../../../../../components/device_status_modal.dart'; import '../../../../v2/site/presentation/cubit/site_cubit.dart'; import '../../../site/presentation/widgets/site_selector_widget.dart'; +import '../../../../main_container/domain/tab_config.dart'; import '../bloc/device_status_bloc.dart' as DeviceListBloc; import '../bloc/device_status_event.dart' as DeviceListEvent; import '../bloc/device_status_state.dart' as DeviceListState; @@ -27,6 +29,7 @@ import '../widgets/bluetooth_scan_modal.dart'; import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入 import 'robot_list_page.dart'; import 'robot_control_page.dart'; +import 'ble_device_detail_page.dart'; import 'drone_station_detail_page.dart'; import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; import 'qr_scanner_page.dart'; @@ -74,6 +77,23 @@ class _DeviceStatusViewState extends State { void initState() { super.initState(); _currentSiteId = sl().state.selectedSite?.id; + // personal 角色隐藏了场站选择器(不再由 SiteSelectorWidget 触发 loadSites), + // 这里主动加载场站并默认选中,保证设备数据能正常展示: + // 1) loadSites 内部有幂等守卫,会自动选中(恢复上次或首个)场站; + // 2) 兜底:若加载后仍未选中(如 loadSites 因已加载而提前返回、 + // 或历史选中项被清除),且存在场站,则默认选中第一个; + // 3) 选中后经 _siteSub 监听触发设备列表刷新。 + if (_isPersonalRole) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final siteCubit = sl(); + await siteCubit.loadSites(); + if (!mounted) return; + final siteState = siteCubit.state; + if (siteState.selectedSite == null && siteState.sites.isNotEmpty) { + siteCubit.selectSite(siteState.sites.first); + } + }); + } _siteSub = sl().stream.listen((siteState) { if (!mounted) return; final newSiteId = siteState.selectedSite?.id; @@ -85,6 +105,10 @@ class _DeviceStatusViewState extends State { }); } + /// 当前登录用户是否为个人角色(roleKey == 'personal') + bool get _isPersonalRole => + sl().state.user?.roleKey == TabConfig.personalRoleKey; + @override void dispose() { _siteSub?.cancel(); @@ -174,6 +198,7 @@ class _DeviceStatusViewState extends State { } Widget _buildAppBar(BuildContext context) { + final isPersonal = _isPersonalRole; return Container( height: 44.0, padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -182,10 +207,12 @@ class _DeviceStatusViewState extends State { Expanded( child: Row( children: [ - const Flexible(child: SiteSelectorWidget(compact: true)), - const SizedBox(width: 8), - Container(width: 1, height: 20, color: const Color(0xFFE5E6EB)), - const SizedBox(width: 8), + if (!isPersonal) ...[ + const Flexible(child: SiteSelectorWidget(compact: true)), + const SizedBox(width: 8), + Container(width: 1, height: 20, color: const Color(0xFFE5E6EB)), + const SizedBox(width: 8), + ], Text( AppLocalizations.of( context, @@ -354,24 +381,22 @@ class _DeviceStatusViewState extends State { } Widget _buildTypeFilterBar(BuildContext context) { - final typeCodes = [ - 'all', - 'robot', - 'drone_station', - 'inverter', - 'combiner_box', - 'module', - 'monitor', - ]; - final typeLabels = [ - AppLocalizations.of(context).translate('device_list_v2.all'), - AppLocalizations.of(context).translate('device_list_v2.robot'), - AppLocalizations.of(context).translate('device_list_v2.drone_station'), - AppLocalizations.of(context).translate('device_list_v2.inverter'), - AppLocalizations.of(context).translate('device_list_v2.combiner_box'), - AppLocalizations.of(context).translate('device_list_v2.module'), - AppLocalizations.of(context).translate('device_list_v2.monitor'), - ]; + final isPersonal = _isPersonalRole; + final typeCodes = isPersonal + ? ['all', 'robot', 'drone_station'] + : [ + 'all', + 'robot', + 'drone_station', + 'inverter', + 'combiner_box', + 'module', + 'monitor', + ]; + final typeLabels = typeCodes + .map((code) => + AppLocalizations.of(context).translate('device_list_v2.$code')) + .toList(); return BlocBuilder< DeviceListBloc.DeviceStatusBloc, @@ -480,6 +505,7 @@ class _DeviceStatusViewState extends State { } if (state is DeviceListState.DeviceStatusLoaded) { + final isPersonal = _isPersonalRole; List filteredByType = state.devices; if (state.selectedType != 'all') { filteredByType = state.devices.where((device) { @@ -496,7 +522,11 @@ class _DeviceStatusViewState extends State { device.name.startsWith(prefix) || device.deviceId.startsWith(prefix), ); - return !isRobot; + if (isRobot) return false; + // personal 角色只允许 全部/机器人/无人机; + // 机器人与无人机已由专用组件渲染,这里排除其余所有普通设备(逆变器/汇流箱/组件/监控) + if (isPersonal) return false; + return true; }).toList(); } @@ -611,7 +641,34 @@ class _DeviceStatusViewState extends State { debugPrint( '🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}', ); + debugPrint( + '🔴🔴🔴 [全部-选中机器人] hasHost: ${robot.hasHost}', + ); + // 🔥 hasHost 字段判定:false → 蓝牙连接流程 + if (!robot.hasHost) { + debugPrint( + '⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程', + ); + final timestamp = _extractTimestamp(robot.name); + if (timestamp == null || timestamp.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('设备名称格式错误,无法提取时间戳'), + duration: Duration(seconds: 2), + ), + ); + return; + } + _connectToDeviceViaBluetooth( + context, + robot.name, + timestamp, + ); + return; + } + + // hasHost == true → 走原来的逻辑 // 1. 将当前机器人设置为全局待控制设备 final device = DeviceEntity( deviceName: robot.name, @@ -792,7 +849,18 @@ class _DeviceStatusViewState extends State { DeviceListState.DeviceStatusLoaded state, ) { // 从实际设备列表计算统计数据(不再使用硬编码的假数据) - final devices = state.devices; + // personal 角色列表只展示 机器人 + 无人机机场,统计口径需与列表保持一致, + // 否则会出现「共 N 台」与下方可见设备数量对不上的问题。 + final devices = _isPersonalRole + ? state.devices.where((device) { + if (device.type == 'drone_station') return true; + return _robotPrefixes.any( + (prefix) => + device.name.startsWith(prefix) || + device.deviceId.startsWith(prefix), + ); + }).toList() + : state.devices; final totalCount = devices.length; final onlineCount = devices.where((d) => d.status == '在线').length; final exceptionCount = devices.where((d) => d.status == '异常').length; @@ -1166,4 +1234,178 @@ class _DeviceStatusViewState extends State { ); } } + + /// 🔥 从设备名中提取13位时间戳 + String? _extractTimestamp(String deviceName) { + final regex = RegExp(r'(\d{13})'); + final match = regex.firstMatch(deviceName); + return match?.group(1); + } + + /// 🔥 通过蓝牙连接设备 + Future _connectToDeviceViaBluetooth( + BuildContext context, + String deviceName, + String timestamp, + ) async { + final bleManager = BleManager.instance; + + // 0. 优先检查:如果该设备已经连接,直接跳转详情页,不再扫描 + final connectedDevice = bleManager.connectedDevice; + if (connectedDevice != null) { + final connectedName = bleManager.connectedDeviceName ?? ''; + debugPrint('🔍 [蓝牙连接] 已连接设备名: $connectedName,匹配时间戳: $timestamp'); + if (connectedName.contains(timestamp)) { + debugPrint('✅ [蓝牙连接] 设备已连接,直接跳转详情页: $connectedName'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: connectedDevice), + ), + ); + return; + } + } + + // 1. 检查蓝牙是否打开 + final isBluetoothOn = await bleManager.checkBluetooth(); + if (!isBluetoothOn) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先打开手机蓝牙'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + return; + } + + // 2. 显示加载圈 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + '正在搜索蓝牙设备...', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ); + + // 3. 启动蓝牙扫描 + try { + await bleManager.startScan(continuous: false); + } catch (e) { + debugPrint('❌ [蓝牙连接] 启动扫描失败: $e'); + } + + // 4. 等待扫描结果 + await Future.delayed(const Duration(milliseconds: 1500)); + + // 5. 关闭加载圈 + if (!context.mounted) return; + Navigator.of(context).pop(); + + // 6. 获取扫描结果查找匹配设备 + final scanResults = bleManager.getScanResults(); + BluetoothDevice? matchedDevice; + for (final result in scanResults) { + final dev = result.device; + final devName = dev.platformName.isEmpty + ? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName) + : dev.platformName; + if (devName.contains(timestamp)) { + matchedDevice = dev; + break; + } + } + + if (matchedDevice != null) { + final device = matchedDevice; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: device), + ), + ); + } else { + _showNotFoundDialogAndMonitor(context, deviceName, timestamp); + } + } + + /// 🔥 未找到设备弹窗 + 后台持续监测(只监测最新点击的设备) + void _showNotFoundDialogAndMonitor( + BuildContext context, + String deviceName, + String timestamp, + ) { + // 检查是否已有弹窗存在,如果有则先关闭(防止弹窗叠加) + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + bool dialogShown = true; + StreamSubscription? subscription; + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + dialogShown = true; + return AlertDialog( + title: const Text('未找到设备'), + content: const Text('请确认机器已上线并打开蓝牙了'), + actions: [ + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: const Text('取消'), + ), + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: const Text('确认'), + ), + ], + ); + }, + ).then((_) {}); + + final bleManager = BleManager.instance; + subscription = bleManager.scanResults.listen((results) { + if (!dialogShown) { + subscription?.cancel(); + return; + } + for (final result in results) { + final dev = result.device; + final devName = dev.platformName.isEmpty + ? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName) + : dev.platformName; + if (devName.contains(timestamp)) { + subscription?.cancel(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: dev), + ), + ); + break; + } + } + }); + } } 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 5c5a9d35..69473d0b 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,9 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../../core/di/injection.dart'; import '../../../../../core/logging/i_logger_service.dart'; +import '../../../../../core/bluetooth/ble_manager.dart'; import '../../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../../devices/domain/entities/device_entity.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; @@ -13,8 +17,11 @@ import '../bloc/robot_list_bloc.dart'; import '../bloc/robot_list_event.dart'; import '../bloc/robot_list_state.dart'; import '../widgets/robot_item_card.dart'; +import '../../../../../core/bluetooth/ble_manager.dart'; import 'robot_control_page.dart'; import 'cleaning_weeding_robot_task_page.dart'; +import 'ble_device_detail_page.dart'; +import 'ble_device_detail_page.dart'; /// 机器人列表页面 class RobotListPage extends StatelessWidget { @@ -86,7 +93,8 @@ class _RobotListViewState extends State { children: [ _buildStatsCard(state), _buildQuickActions(state), - _buildCurrentTask(state), + // 🔥 暂时注释掉「当前任务」信息栏(展示的是硬编码假数据) + // _buildCurrentTask(state), ..._buildRobotList(state), ], ), @@ -631,9 +639,34 @@ class _RobotListViewState extends State { '🔴🔴🔴 [选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}', ); debugPrint( - '🔴🔴🔴 [选中机器人] status: ${robot.status}, battery: ${robot.battery}, task: ${robot.task}', + '🔴🔴🔴 [选中机器人] hasHost: ${robot.hasHost}', ); + // 🔥 hasHost 字段判定:false → 蓝牙连接流程 + if (!robot.hasHost) { + debugPrint('⚠️ [RobotListPage] hasHost=false,进入蓝牙连接流程'); + + // 从设备名中提取时间戳 + final timestamp = _extractTimestamp(robot.name); + if (timestamp == null || timestamp.isEmpty) { + debugPrint('❌ [RobotListPage] 未提取到时间戳'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('设备名称格式错误,无法提取时间戳'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + debugPrint('✅ [RobotListPage] 提取到时间戳: $timestamp'); + _connectToDeviceViaBluetooth(context, robot.name, timestamp); + return; + } + + // hasHost == true → 走原来的逻辑 + debugPrint('✅ [RobotListPage] hasHost=true,走正常控制流程'); + // 1. 将当前机器人设置为全局待控制设备(用 robot.name 作为 deviceName) final device = DeviceEntity( deviceName: robot.name, // ✅ 修正: 用 name 而不是 id @@ -674,4 +707,178 @@ class _RobotListViewState extends State { ) .toList(); } + + /// 🔥 从设备名中提取13位时间戳 + String? _extractTimestamp(String deviceName) { + final regex = RegExp(r'(\d{13})'); + final match = regex.firstMatch(deviceName); + return match?.group(1); + } + + /// 🔥 通过蓝牙连接设备 + Future _connectToDeviceViaBluetooth( + BuildContext context, + String deviceName, + String timestamp, + ) async { + final bleManager = BleManager.instance; + + // 0. 优先检查:如果该设备已经连接,直接跳转详情页,不再扫描 + final connectedDevice = bleManager.connectedDevice; + if (connectedDevice != null) { + final connectedName = bleManager.connectedDeviceName ?? ''; + debugPrint('🔍 [蓝牙连接] 已连接设备名: $connectedName,匹配时间戳: $timestamp'); + if (connectedName.contains(timestamp)) { + debugPrint('✅ [蓝牙连接] 设备已连接,直接跳转详情页: $connectedName'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: connectedDevice), + ), + ); + return; + } + } + + // 1. 检查蓝牙是否打开 + final isBluetoothOn = await bleManager.checkBluetooth(); + if (!isBluetoothOn) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先打开蓝牙'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + return; + } + + // 2. 显示加载圈 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + '正在搜索蓝牙设备...', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ); + + // 3. 启动蓝牙扫描 + try { + await bleManager.startScan(continuous: false); + } catch (e) { + debugPrint('❌ [蓝牙连接] 启动扫描失败: $e'); + } + + // 4. 等待扫描结果 + await Future.delayed(const Duration(milliseconds: 1500)); + + // 5. 关闭加载圈 + if (!context.mounted) return; + Navigator.of(context).pop(); + + // 6. 获取扫描结果查找匹配设备 + final scanResults = bleManager.getScanResults(); + BluetoothDevice? matchedDevice; + for (final result in scanResults) { + final dev = result.device; + final devName = dev.platformName.isEmpty + ? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName) + : dev.platformName; + if (devName.contains(timestamp)) { + matchedDevice = dev; + break; + } + } + + if (matchedDevice != null) { + final device = matchedDevice; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: device), + ), + ); + } else { + _showNotFoundDialogAndMonitor(context, deviceName, timestamp); + } + } + + /// 🔥 未找到设备弹窗 + 后台持续监测(只监测最新点击的设备) + void _showNotFoundDialogAndMonitor( + BuildContext context, + String deviceName, + String timestamp, + ) { + // 检查是否已有弹窗存在,如果有则先关闭(防止弹窗叠加) + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + bool dialogShown = true; + StreamSubscription? subscription; + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + dialogShown = true; + return AlertDialog( + title: const Text('未找到设备'), + content: const Text('请确认机器已上线并打开蓝牙了'), + actions: [ + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: const Text('取消'), + ), + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: const Text('确认'), + ), + ], + ); + }, + ).then((_) {}); + + final bleManager = BleManager.instance; + subscription = bleManager.scanResults.listen((results) { + if (!dialogShown) { + subscription?.cancel(); + return; + } + for (final result in results) { + final dev = result.device; + final devName = dev.platformName.isEmpty + ? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName) + : dev.platformName; + if (devName.contains(timestamp)) { + subscription?.cancel(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage(device: dev), + ), + ); + break; + } + } + }); + } } diff --git a/lib/features/v2/my/presentation/pages/system_settings_page.dart b/lib/features/v2/my/presentation/pages/system_settings_page.dart index 4a8983fb..8f57058f 100644 --- a/lib/features/v2/my/presentation/pages/system_settings_page.dart +++ b/lib/features/v2/my/presentation/pages/system_settings_page.dart @@ -9,6 +9,8 @@ import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; +import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; /// 系统设置综合页面 @@ -121,6 +123,8 @@ class _SystemSettingsPageState extends State { /// Tab 设置 Widget _buildTabSettingsSection() { + final roleKey = context.watch().state.user?.roleKey; + final isPersonal = roleKey == TabConfig.personalRoleKey; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -152,7 +156,7 @@ class _SystemSettingsPageState extends State { return const Center(child: CircularProgressIndicator()); } - final tabs = state.config.items; + final tabs = state.config.settingItemsForRole(roleKey); return ListView.builder( shrinkWrap: true, @@ -174,10 +178,12 @@ class _SystemSettingsPageState extends State { ), ), Switch( - value: tab.isEnabled, - onChanged: (value) { - context.read().toggleTab(tab.id); - }, + value: isPersonal ? true : tab.isEnabled, + onChanged: isPersonal + ? null + : (value) { + context.read().toggleTab(tab.id); + }, activeColor: const Color(0xFF165DFF), ), ],