AIR版的功能添加

This commit is contained in:
2026-09-18 16:59:32 +08:00
parent fb2f32f423
commit bb92fbd1e0
8 changed files with 670 additions and 176 deletions

View File

@@ -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<ScanResult> getScanResults() {
return _scanResults.values.toList();
}
Stream<List<ScanResult>> get scanResults => _scanController.stream;
Stream<BlePacket> get packetStream => _packetController.stream;
Stream<BluetoothAdapterState> get adapterState =>
@@ -238,6 +250,17 @@ class BleManager {
/// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
Future<String?> 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;

View File

@@ -44,12 +44,47 @@ class TabConfig extends Equatable {
const TabConfig({required this.items});
/// 个人角色标识(登录接口返回的 roleKey)
static const String personalRoleKey = 'personal';
/// 个人角色可见的 Tab ID 集合(单一数据源)
static const Set<String> personalVisibleTabIds = {'device', 'me'};
List<TabConfigItem> 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<TabConfigItem> 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<TabConfigItem> 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<TabConfigItem>? items}) {
return TabConfig(items: items ?? this.items);
}

View File

@@ -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<MainWrapper> {
int _currentIndex = 0;
final PageController _pageController = PageController();
final List<GlobalKey> _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<MainWrapper> {
// 🔥 确保 FloatBarSettingService 已初始化
GetIt.I<FloatBarSettingService>();
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<MainWrapper> {
}
}
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<MainWrapper> {
@override
Widget build(BuildContext context) {
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
return BlocBuilder<TabConfigCubit, TabConfigState>(
builder: (context, state) {
if (state is! TabConfigLoaded) {
@@ -156,39 +119,32 @@ class _MainWrapperState extends State<MainWrapper> {
}
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<MainWrapper> {
),
],
),
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(),
),
],
),
),
);
},

View File

@@ -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,
);
}

View File

@@ -52,6 +52,14 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
super.initState();
_initConnectionListener();
_syncInitialState();
// 🔥 页面加载完成后自动连接设备(如果尚未连接且未在连接中)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isConnected && !_isConnecting) {
_connect();
}
});
}
/// 监听 BleManager 的全局连接状态流

View File

@@ -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<DeviceStatusView> {
void initState() {
super.initState();
_currentSiteId = sl<SiteCubit>().state.selectedSite?.id;
// personal 角色隐藏了场站选择器(不再由 SiteSelectorWidget 触发 loadSites),
// 这里主动加载场站并默认选中,保证设备数据能正常展示:
// 1) loadSites 内部有幂等守卫,会自动选中(恢复上次或首个)场站;
// 2) 兜底:若加载后仍未选中(如 loadSites 因已加载而提前返回、
// 或历史选中项被清除),且存在场站,则默认选中第一个;
// 3) 选中后经 _siteSub 监听触发设备列表刷新。
if (_isPersonalRole) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final siteCubit = sl<SiteCubit>();
await siteCubit.loadSites();
if (!mounted) return;
final siteState = siteCubit.state;
if (siteState.selectedSite == null && siteState.sites.isNotEmpty) {
siteCubit.selectSite(siteState.sites.first);
}
});
}
_siteSub = sl<SiteCubit>().stream.listen((siteState) {
if (!mounted) return;
final newSiteId = siteState.selectedSite?.id;
@@ -85,6 +105,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
});
}
/// 当前登录用户是否为个人角色(roleKey == 'personal')
bool get _isPersonalRole =>
sl<AppUserCubit>().state.user?.roleKey == TabConfig.personalRoleKey;
@override
void dispose() {
_siteSub?.cancel();
@@ -174,6 +198,7 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
}
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<DeviceStatusView> {
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<DeviceStatusView> {
}
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<DeviceStatusView> {
}
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<DeviceStatusView> {
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<DeviceStatusView> {
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<DeviceStatusView> {
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<DeviceStatusView> {
);
}
}
/// 🔥 从设备名中提取13位时间戳
String? _extractTimestamp(String deviceName) {
final regex = RegExp(r'(\d{13})');
final match = regex.firstMatch(deviceName);
return match?.group(1);
}
/// 🔥 通过蓝牙连接设备
Future<void> _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;
}
}
});
}
}

View File

@@ -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<RobotListView> {
children: [
_buildStatsCard(state),
_buildQuickActions(state),
_buildCurrentTask(state),
// 🔥 暂时注释掉「当前任务」信息栏(展示的是硬编码假数据)
// _buildCurrentTask(state),
..._buildRobotList(state),
],
),
@@ -631,9 +639,34 @@ class _RobotListViewState extends State<RobotListView> {
'🔴🔴🔴 [选中机器人] 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<RobotListView> {
)
.toList();
}
/// 🔥 从设备名中提取13位时间戳
String? _extractTimestamp(String deviceName) {
final regex = RegExp(r'(\d{13})');
final match = regex.firstMatch(deviceName);
return match?.group(1);
}
/// 🔥 通过蓝牙连接设备
Future<void> _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;
}
}
});
}
}

View File

@@ -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<SystemSettingsPage> {
/// Tab 设置
Widget _buildTabSettingsSection() {
final roleKey = context.watch<AppUserCubit>().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<SystemSettingsPage> {
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<SystemSettingsPage> {
),
),
Switch(
value: tab.isEnabled,
onChanged: (value) {
context.read<TabConfigCubit>().toggleTab(tab.id);
},
value: isPersonal ? true : tab.isEnabled,
onChanged: isPersonal
? null
: (value) {
context.read<TabConfigCubit>().toggleTab(tab.id);
},
activeColor: const Color(0xFF165DFF),
),
],