- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
221 lines
8.9 KiB
Dart
221 lines
8.9 KiB
Dart
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 '../../core/app_theme.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 GradientScaffold(
|
||
appBar: AppBar(
|
||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop()),
|
||
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 '弱';
|
||
}
|
||
}
|