AIR版的功能添加
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,20 +161,32 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
),
|
||||
child: SafeArea(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
@@ -236,27 +204,26 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 滑动圆形背景
|
||||
if (_circleLeft != null)
|
||||
// 滑动圆形指示器(纯数学定位,杜绝错位/抖动)
|
||||
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,
|
||||
color: context.appColors.primary.withOpacity(
|
||||
0.12,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.appColors.divider,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tab 项
|
||||
// Tab 项(用 Expanded 弹性平分,避免窄屏下固定宽度溢出)
|
||||
Row(
|
||||
children: enabledItems.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
@@ -264,20 +231,21 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
final isSelected = _currentIndex == index;
|
||||
|
||||
return Expanded(
|
||||
key: _tabKeys[index],
|
||||
child: InkWell(
|
||||
onTap: () => _onTabChanged(index),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getIconData(item.icon),
|
||||
size: 24,
|
||||
color: isSelected
|
||||
? context.appColors.textPrimary
|
||||
? context.appColors.primary
|
||||
: context.appColors.textTertiary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -286,7 +254,7 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isSelected
|
||||
? context.appColors.textPrimary
|
||||
? context.appColors.primary
|
||||
: context.appColors.textTertiary,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
@@ -302,6 +270,7 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
super.initState();
|
||||
_initConnectionListener();
|
||||
_syncInitialState();
|
||||
|
||||
// 🔥 页面加载完成后自动连接设备(如果尚未连接且未在连接中)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isConnected && !_isConnecting) {
|
||||
_connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 监听 BleManager 的全局连接状态流
|
||||
|
||||
@@ -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: [
|
||||
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,7 +381,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
}
|
||||
|
||||
Widget _buildTypeFilterBar(BuildContext context) {
|
||||
final typeCodes = [
|
||||
final isPersonal = _isPersonalRole;
|
||||
final typeCodes = isPersonal
|
||||
? ['all', 'robot', 'drone_station']
|
||||
: [
|
||||
'all',
|
||||
'robot',
|
||||
'drone_station',
|
||||
@@ -363,15 +393,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
'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 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +178,10 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: tab.isEnabled,
|
||||
onChanged: (value) {
|
||||
value: isPersonal ? true : tab.isEnabled,
|
||||
onChanged: isPersonal
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TabConfigCubit>().toggleTab(tab.id);
|
||||
},
|
||||
activeColor: const Color(0xFF165DFF),
|
||||
|
||||
Reference in New Issue
Block a user