Files
AI-Health/health_app/lib/pages/device/device_scan_page.dart
MingNian 63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
2026-06-10 18:27:38 +08:00

217 lines
8.8 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 (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
try { await FlutterBluePlus.stopScan(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
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 (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
});
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.iconBg,
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.primary,
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.primary, width: 1.5) : null,
),
child: Row(children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: isBP ? AppColors.iconBg : AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.primary : 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.primary, 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.primaryGradient, 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.primary, 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 '';
}
}