refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程

- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
This commit is contained in:
MingNian
2026-06-28 22:53:35 +08:00
parent a748c316f2
commit 4507083f3f
20 changed files with 1936 additions and 4428 deletions

View File

@@ -1,38 +1,42 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../models/ble_device.dart';
import '../../models/bp_reading.dart';
import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
import '../../services/omron_ble_service.dart';
import '../../services/health_ble_service.dart';
import '../../widgets/ble_sync_dialog.dart';
class DeviceScanPage extends ConsumerStatefulWidget {
const DeviceScanPage({super.key});
@override
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[];
bool _scanning = false;
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId;
StreamSubscription? _scanSub;
DateTime? _scanStartedAt;
bool _scanning = false;
bool _connected = false;
String _deviceName = '';
bool _readingReceived = false;
int? _systolic, _diastolic, _pulse;
StreamSubscription? _readingSub;
StreamSubscription? _bleConnSub;
late AnimationController _pulseCtrl;
late Animation<double> _pulseAnim;
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
@@ -40,40 +44,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
)..repeat(reverse: true);
_pulseAnim = Tween(
begin: 0.8,
end: 1.4,
begin: 0.82,
end: 1.36,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
_pulseCtrl.repeat(reverse: true);
_scanSub = FlutterBluePlus.scanResults.listen((list) {
if (!mounted) return;
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
setState(() {
_results.clear();
_results.addAll(bpList);
});
});
_start();
unawaited(_startScan());
}
@override
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel();
_readingSub?.cancel();
_bleConnSub?.cancel();
FlutterBluePlus.stopScan();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
super.dispose();
}
Future<void> _start() async {
Future<void> _startScan() async {
setState(() {
_scanning = true;
_connected = false;
_connectingId = null;
_results.clear();
});
_results.clear();
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
@@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开GPS定位否则无法扫描蓝牙'),
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
);
@@ -99,398 +95,331 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency,
oneByOne: true,
);
Future.delayed(const Duration(seconds: 30), () {
FlutterBluePlus.stopScan();
_scanStopTimer?.cancel();
_scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false);
});
_resultPruneTimer?.cancel();
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectingId != null) return;
final pruned = _visibleResults(_results);
if (_sameResults(_results, pruned)) return;
setState(() {
_results
..clear()
..addAll(pruned);
});
});
}
Future<void> _connect(ScanResult r) async {
setState(() => _connectingId = r.device.remoteId.toString());
FlutterBluePlus.stopScan();
_scanSub?.cancel();
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in supported) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
if (index >= 0) {
next[index] = result;
} else {
next.add(result);
}
}
final visible = _visibleResults(next);
if (_sameResults(_results, visible)) return;
setState(() {
_results
..clear()
..addAll(visible);
});
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
return;
}
setState(() => _connectingId = remoteId);
await FlutterBluePlus.stopScan();
final ble = ref.read(healthBleServiceProvider);
BoundBleDevice? boundDevice;
try {
final ble = ref.read(omronBleServiceProvider);
await ble.connect(r.device);
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
boundDevice = await ble
.connectAndIdentify(
device: result.device,
name: _deviceNameOf(result),
)
.timeout(const Duration(seconds: 15));
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
await ref
.read(omronDeviceProvider.notifier)
.bind(r.device.remoteId.toString(), name);
_bleConnSub = r.device.connectionState.listen((s) {
if (s == BluetoothConnectionState.disconnected && mounted) {
_readingSub?.cancel();
ref.read(omronBleServiceProvider).disconnect();
if (_readingReceived) {
popRoute(ref);
} else {
setState(() {
_connected = false;
});
_start();
if (boundDevice.type == BleDeviceType.bloodPressure) {
final syncResult = await ble.syncBoundDevice(
result.device,
boundDevice,
timeout: const Duration(seconds: 30),
);
if (syncResult is BloodPressureSyncResult) {
final saved = await _persistBloodPressure(
syncResult.device,
syncResult.reading,
);
if (mounted && saved) {
await _showBloodPressureDialog(syncResult.reading);
}
}
});
_readingSub = ble.readings.listen((reading) async {
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
if (mounted) {
setState(() {
_readingReceived = true;
_systolic = reading.systolic;
_diastolic = reading.diastolic;
_pulse = reading.pulse;
});
try {
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (e) {
debugPrint('[PAGE] 上报失败: $e');
}
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) popRoute(ref);
});
}
});
} else if (mounted) {
popRoute(ref);
}
if (mounted) popRoute(ref);
} on TimeoutException {
if (mounted && boundDevice != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
);
popRoute(ref);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('连接超时,请确认设备仍处于通信状态'),
backgroundColor: AppColors.error,
),
);
unawaited(_startScan());
}
} on UnsupportedBleDeviceException catch (e) {
if (mounted) {
setState(() {
_connected = true;
_deviceName = name;
_connectingId = null;
_readingReceived = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
unawaited(_startScan());
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
SnackBar(content: Text('连接失败$e'), backgroundColor: AppColors.error),
);
_start();
unawaited(_startScan());
}
} finally {
await ble.disconnect();
if (mounted) setState(() => _connectingId = null);
}
}
Future<bool> _persistBloodPressure(
BoundBleDevice device,
BpReading reading,
) async {
final notifier = ref.read(omronDeviceProvider.notifier);
if (await notifier.isDuplicateReading(device, reading)) return false;
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await notifier.recordSuccessfulSync(device: device, reading: reading);
return true;
}
Future<void> _showBloodPressureDialog(BpReading reading) {
return showBleSyncDialog(context, reading: reading);
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
backgroundColor: Colors.white.withValues(alpha: 0.92),
elevation: 0,
title: Text(
_connected ? _deviceName : '添加设备',
style: const TextStyle(color: AppColors.textPrimary),
title: const Text(
'新增设备',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
if (_connected) ref.read(omronBleServiceProvider).disconnect();
popRoute(ref);
},
onPressed: () => popRoute(ref),
),
),
body: _connected ? _buildConnected() : _buildScan(),
body: _results.isEmpty
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
itemCount: _results.length,
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final result = _results[index];
return _DeviceResultTile(
result: result,
connecting:
_connectingId == result.device.remoteId.toString(),
onConnect: () => _connectBindAndSync(result),
);
},
),
);
}
// ── 扫描视图 ──
Widget _buildScan() => Column(
children: [
if (_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (!_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (_results.isNotEmpty)
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _results.length,
itemBuilder: (_, i) => _buildTile(_results[i]),
),
),
if (!_scanning)
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _start,
icon: const Icon(Icons.refresh),
label: const Text('重新扫描'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
],
);
String _deviceNameOf(ScanResult result) {
final advName = result.advertisementData.advName.trim();
if (advName.isNotEmpty) return advName;
final platformName = result.device.platformName.trim();
if (platformName.isNotEmpty) return platformName;
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
}
Widget _buildScanningCenter() => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
if (current.length != next.length) return false;
for (var i = 0; i < current.length; i++) {
if (current[i].device.remoteId != next[i].device.remoteId) return false;
}
return true;
}
}
class _ScanningEmpty extends StatelessWidget {
final Animation<double> animation;
final bool scanning;
const _ScanningEmpty({required this.animation, required this.scanning});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Container(
width: 80 * _pulseAnim.value,
height: 80 * _pulseAnim.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.auraIndigo.withValues(alpha: 0.10),
),
_ScanPulse(animation: animation),
const SizedBox(height: 22),
Text(
scanning ? '正在搜索设备' : '暂未发现设备',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: AppColors.buttonShadow,
),
child: const Icon(
Icons.bluetooth_searching,
size: 32,
color: Colors.white,
),
),
],
),
const SizedBox(height: 20),
Text(
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
),
const SizedBox(height: 8),
Text(
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
),
],
),
);
// ── 已连接视图 ──
Widget _buildConnected() => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
width: 100,
height: 100,
decoration: BoxDecoration(
gradient: _readingReceived
? AppColors.calmHealthGradient
: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(32),
boxShadow: AppColors.buttonShadow,
),
child: Icon(
_readingReceived
? Icons.check_rounded
: Icons.bluetooth_connected,
size: 48,
color: _readingReceived ? Colors.white : Colors.white,
),
),
const SizedBox(height: 24),
Text(
_readingReceived ? '测量完成' : '设备已连接',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
if (_readingReceived) ...[
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$_systolic',
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
'/',
style: TextStyle(
fontSize: 24,
color: AppColors.textHint.withValues(alpha: 0.5),
),
),
),
Text(
'$_diastolic',
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1,
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'mmHg',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
),
],
),
if (_pulse != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.infoLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'脉搏 $_pulse bpm',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
),
),
const SizedBox(height: 16),
const Text(
'数据已自动同步',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
const SizedBox(height: 8),
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
] else ...[
const Text(
'按下血压计的开始键进行测量',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, _) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Transform.scale(
scale: _pulseAnim.value,
child: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary,
),
),
),
),
'让设备进入通信状态并靠近手机。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.45,
color: AppColors.textSecondary,
),
),
],
],
),
),
),
);
);
}
}
// ── 设备列表项 ──
Widget _buildTile(ScanResult r) {
final isConnecting = _connectingId == r.device.remoteId.toString();
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
class _DeviceResultTile extends StatelessWidget {
final ScanResult result;
final bool connecting;
final VoidCallback onConnect;
const _DeviceResultTile({
required this.result,
required this.connecting,
required this.onConnect,
});
@override
Widget build(BuildContext context) {
final type = HealthBleService.deviceTypeFromScan(result);
final name = _deviceNameOf(result, type);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(16),
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
),
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
type?.label ?? '健康设备',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
const SizedBox(height: 3),
Text(
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
@@ -499,43 +428,60 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
],
),
),
if (isConnecting)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
GestureDetector(
onTap: () => _connect(r),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: AppColors.buttonShadow,
),
child: const Text(
'连接',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
FilledButton(
onPressed: connecting ? null : onConnect,
child: Text(connecting ? '连接中' : '连接'),
),
],
),
);
}
String _rssiLabel(int r) {
if (r > -55) return '';
if (r > -70) return '';
return '';
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
final advName = result.advertisementData.advName.trim();
if (advName.isNotEmpty) return advName;
final platformName = result.device.platformName.trim();
if (platformName.isNotEmpty) return platformName;
return type?.label ?? '健康设备';
}
}
class _ScanPulse extends StatelessWidget {
final Animation<double> animation;
const _ScanPulse({required this.animation});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: animation,
builder: (context, child) => Container(
width: 78 * animation.value,
height: 78 * animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.08),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight),
),
child: const Icon(
Icons.bluetooth_searching_rounded,
size: 32,
color: AppColors.primary,
),
),
],
);
}
}