Files
AI-Health/health_app/lib/pages/device/device_scan_page.dart
MingNian b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00

566 lines
18 KiB
Dart

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_design_tokens.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/health_ble_service.dart';
import '../../widgets/app_toast.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 {
final _results = <ScanResult>[];
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
String? _connectingId;
bool _scanning = false;
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_pulseAnim = Tween(
begin: 0.82,
end: 1.36,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
unawaited(_startScan());
}
@override
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_scanSub?.cancel();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
super.dispose();
}
Future<void> _startScan() async {
setState(() {
_scanning = true;
_connectingId = null;
_results.clear();
});
if (!await _ensureBlePermissions()) {
if (mounted) setState(() => _scanning = false);
return;
}
if (Platform.isAndroid) {
final locStatus = await Permission.locationWhenInUse.serviceStatus;
if (locStatus != ServiceStatus.enabled && mounted) {
AppToast.show(
context,
'请先打开定位服务,否则可能无法发现蓝牙设备',
type: AppToastType.warning,
);
}
}
final adapterState = await FlutterBluePlus.adapterState.first;
if (adapterState != BluetoothAdapterState.on) {
try {
await FlutterBluePlus.turnOn();
} catch (_) {}
}
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency,
oneByOne: true,
);
_scanStopTimer?.cancel();
_scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false);
});
}
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final candidates = list.where((result) {
// Keep the result in the list first. Many health devices expose their
// standard service only after connection, not in the advertisement.
// Also avoid relying on ScanResult.timeStamp/connectable here: both can
// be stale or conservative while a device is in communication mode.
if (!_looksLikeHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in candidates) {
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]
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
bool _looksLikeHealthDevice(ScanResult result) {
if (HealthBleService.deviceTypeFromScan(result) != null) return true;
final name = [
result.advertisementData.advName,
result.device.platformName,
].join(' ').toUpperCase();
return name.contains('OMRON') ||
name.contains('HEM') ||
name.contains('BLESMART') ||
name.contains('J735') ||
name.contains('BPM') ||
name.contains('BP');
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
return;
}
setState(() => _connectingId = remoteId);
await FlutterBluePlus.stopScan();
final ble = ref.read(healthBleServiceProvider);
BoundBleDevice? boundDevice;
try {
boundDevice = await ble
.connectAndIdentify(
device: result.device,
name: _deviceNameOf(result),
)
.timeout(const Duration(seconds: 15));
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
if (mounted) {
AppToast.show(
context,
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
type: AppToastType.info,
);
unawaited(_startScan());
}
return;
}
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
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);
}
}
}
if (mounted) popRoute(ref);
} on TimeoutException {
if (mounted && boundDevice != null) {
AppToast.show(
context,
'${boundDevice.type.label}已绑定,但本次未收到数据',
type: AppToastType.warning,
);
popRoute(ref);
} else if (mounted) {
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
unawaited(_startScan());
}
} on UnsupportedBleDeviceException catch (e) {
if (mounted) {
AppToast.show(context, e.message, type: AppToastType.error);
unawaited(_startScan());
}
} catch (e) {
if (mounted) {
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
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);
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
records.add(heartRateRecord);
}
await api.post('/api/health-records/batch', data: records);
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(
title: const Text(
'新增设备',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: _results.isEmpty
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
children: [
ClipRRect(
borderRadius: AppRadius.lgBorder,
child: ColoredBox(
color: Colors.white,
child: Column(
children: [
for (var index = 0; index < _results.length; index++)
_DeviceResultTile(
result: _results[index],
connecting:
_connectingId ==
_results[index].device.remoteId.toString(),
showDivider: index < _results.length - 1,
onConnect: () =>
_connectBindAndSync(_results[index]),
),
],
),
),
),
],
),
);
}
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 ?? '健康设备';
}
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;
}
Future<bool> _ensureBlePermissions() async {
final permissions = <Permission>[
if (Platform.isAndroid) Permission.bluetoothScan,
if (Platform.isAndroid) Permission.bluetoothConnect,
// Android 11 and below require a location runtime permission for BLE
// scanning. Requesting it here also keeps older devices from silently
// returning an empty scan stream.
if (Platform.isAndroid) Permission.locationWhenInUse,
];
final statuses = await permissions.request();
final granted = statuses.values.every((status) => status.isGranted);
if (granted) return true;
if (!mounted) return false;
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('需要蓝牙权限'),
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('稍后再说'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
openAppSettings();
},
child: const Text('去设置'),
),
],
),
);
return false;
}
}
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: [
_ScanPulse(animation: animation),
const SizedBox(height: 22),
Text(
scanning ? '正在搜索设备' : '暂未发现设备',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'请让设备进入通信状态并靠近手机。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.45,
color: AppColors.textSecondary,
),
),
],
),
),
);
}
}
class _DeviceResultTile extends StatelessWidget {
final ScanResult result;
final bool connecting;
final bool showDivider;
final VoidCallback onConnect;
const _DeviceResultTile({
required this.result,
required this.connecting,
required this.showDivider,
required this.onConnect,
});
@override
Widget build(BuildContext context) {
final type = HealthBleService.deviceTypeFromScan(result);
final name = _deviceNameOf(result, type);
return Padding(
padding: const EdgeInsets.only(left: 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.device,
borderRadius: AppRadius.smBorder,
),
child: Icon(
type?.icon ?? Icons.bluetooth_rounded,
color: Colors.white,
size: 25,
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
constraints: const BoxConstraints(minHeight: 70),
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
decoration: BoxDecoration(
border: showDivider
? const Border(
bottom: BorderSide(
color: AppColors.divider,
width: 0.7,
),
)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
type?.label ?? '健康设备',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
),
),
const SizedBox(width: 10),
SizedBox(
height: 36,
child: OutlinedButton(
onPressed: connecting ? null : onConnect,
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.device,
disabledForegroundColor: AppColors.textHint,
side: BorderSide(
color: connecting
? AppColors.borderLight
: AppColors.device,
),
padding: const EdgeInsets.symmetric(horizontal: 13),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.smBorder,
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
child: Text(connecting ? '连接中' : '连接'),
),
),
],
),
),
),
],
),
);
}
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.device.withValues(alpha: 0.08),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.device,
borderRadius: AppRadius.xlBorder,
),
child: const Icon(
Icons.bluetooth_searching_rounded,
size: 32,
color: Colors.white,
),
),
],
);
}
}