Files
AI-Health/health_app/lib/pages/device/device_scan_page.dart
MingNian 3964cf2bcb feat: 蓝牙血压计基础架构 + UI配色体系升级
- 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片)
- 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析)
- 新增 BpReading 数据模型 + 血压数据自动同步后端
- 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页)
- 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理
- Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT)
- AppTheme 升级:新阴影系统、新语义色、圆角放大
2026-06-09 18:48:58 +08:00

217 lines
8.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
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 '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
import '../../services/omron_ble_service.dart';
class DeviceScanPage extends ConsumerStatefulWidget {
const DeviceScanPage({super.key});
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
final _results = <ScanResult>[];
bool _scanning = false;
String? _connectingId;
StreamSubscription? _scanSub;
@override void initState() {
super.initState();
// 一次性订阅,持续整个页面生命周期
_scanSub = FlutterBluePlus.scanResults.listen((list) {
if (!mounted) return;
setState(() { _results.clear(); _results.addAll(list); });
});
_start();
}
@override void dispose() {
_scanSub?.cancel();
FlutterBluePlus.stopScan();
super.dispose();
}
Future<void> _start() async {
setState(() => _scanning = true);
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
try { await FlutterBluePlus.turnOn(); } catch (_) {}
try { await FlutterBluePlus.stopScan(); } catch (_) {}
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency,
);
Future.delayed(const Duration(seconds: 30), () {
FlutterBluePlus.stopScan();
if (mounted) setState(() => _scanning = false);
});
}
Future<void> _connect(ScanResult r) async {
setState(() => _connectingId = r.device.remoteId.toString());
FlutterBluePlus.stopScan();
try {
final ble = ref.read(omronBleServiceProvider);
await ble.connect(r.device);
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
await ref.read(omronDeviceProvider.notifier).bind(r.device.remoteId.toString(), name);
ble.readings.listen((reading) async {
try {
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (_) {}
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已连接 $name'), backgroundColor: AppColors.success),
);
Navigator.pop(context);
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('连接失败,请重试'), backgroundColor: AppColors.error),
);
_start();
}
} finally {
if (mounted) setState(() => _connectingId = null);
}
}
// ═══════════════════ UI ═══════════════════
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(backgroundColor: AppColors.cardBackground, title: const Text('添加血压计')),
body: Column(children: [
Container(
width: double.infinity, padding: const EdgeInsets.all(16),
color: AppColors.iconBackground,
child: Row(children: [
if (_scanning) ...[
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))),
const SizedBox(width: 12),
],
Text(_scanning ? '正在扫描...' : '发现 ${_results.length} 台设备',
style: const TextStyle(fontSize: 15, color: Color(0xFF666666))),
]),
),
if (!_scanning && _results.isEmpty) _buildEmpty(),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
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.iconColor,
side: const BorderSide(color: Color(0xFF5B8DEF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
)),
),
]),
);
}
Widget _buildTile(ScanResult r) {
final isBP = OmronBleService.isBpDevice(r);
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
final isConnecting = _connectingId == r.device.remoteId.toString();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16),
boxShadow: AppColors.cardShadow,
border: isBP ? Border.all(color: AppColors.iconColor, width: 1.5) : null,
),
child: Row(children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: isBP ? AppColors.iconBackground : AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.iconColor : AppColors.textHint),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))),
if (isBP) ...[
const SizedBox(width: 6),
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))),
],
]),
const SizedBox(height: 2),
Text('信号: ${_rssiLabel(r.rssi)} · ${r.device.remoteId}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
]),
),
if (isConnecting)
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF)))
else
GestureDetector(
onTap: () => _connect(r),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.purpleBlueGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))),
),
]),
);
}
Widget _buildEmpty() => Padding(
padding: const EdgeInsets.all(32),
child: Column(children: [
Icon(Icons.bluetooth_disabled, size: 64, color: AppColors.textHint),
const SizedBox(height: 16),
const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
const SizedBox(height: 24),
_tip('1', '确保血压计已装电池'),
_tip('2', '长按血压计蓝牙键 3 秒直到图标闪烁'),
_tip('3', '手机靠近血压计1米内'),
_tip('4', '打开手机蓝牙'),
]),
);
Widget _tip(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(fontSize: 14, color: AppColors.textSecondary))),
]),
);
String _rssiLabel(int rssi) {
if (rssi > -55) return '';
if (rssi > -70) return '';
return '';
}
}