feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化

后端
- NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重)
- EF 迁移 AddHealthRecordReminder
- 新增 GET/PUT /api/notification-prefs
- 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒

前端
- 通知偏好对接真实后端 API
- 设置页新增「健康录入提醒」总开关+5 子项
- 通知中心右上角加齿轮跳设置页
- 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案
- 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变
- 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变
- 设置页简化(去掉分组/彩色图标方块)
- 健康档案保存按钮移到右上角,TextButton 全局默认黑色
- API baseUrl 跟随网络环境
This commit is contained in:
MingNian
2026-06-24 22:11:21 +08:00
parent 7ff429e071
commit 53ce19e8c3
18 changed files with 2379 additions and 565 deletions

View File

@@ -1484,39 +1484,44 @@ class ChatMessagesView extends ConsumerWidget {
boText == null &&
wtText == null;
// 异常检测
final bpAbnormal =
bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140;
final hasAbnormal = bpAbnormal;
// ── 多指标异常检测 ──
final abnormals = <String>[];
if (bp is Map && bp['systolic'] is int) {
final s = bp['systolic'] as int;
final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0;
if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高');
}
if (hr is Map && hr['value'] is num) {
final v = (hr['value'] as num).toDouble();
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
}
if (bs is Map && bs['value'] is num) {
final v = (bs['value'] as num).toDouble();
if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高');
}
if (bo is Map && bo['value'] is num) {
final v = (bo['value'] as num).toDouble();
if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低');
}
final hasAbnormal = abnormals.isNotEmpty;
// ── 1. 健康指标 ──
// 没记录则整行不显示(避免每天唠叨提醒)
const healthIconColor = Color(0xFF3B82F6);
const healthIconBg = Color(0xFFDBEAFE);
if (allNull) {
tasks.add(
_taskRow(
context,
Icons.monitor_heart_outlined,
'健康指标',
trailing: '今日暂无记录,点击录入',
status: 'pending',
iconColor: healthIconColor,
iconBg: healthIconBg,
onTap: () => pushRoute(ref, 'trend'),
),
);
// 不显示
} else if (hasAbnormal) {
// 有异常时显示异常指标
final abnormalParts = <String>[];
if (bpAbnormal) {
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
}
final trailing = abnormals.length == 1
? abnormals.first
: '${abnormals.length} 项指标异常';
tasks.add(
_taskRow(
context,
Icons.warning_amber_rounded,
'健康指标',
trailing: abnormalParts.join(' · '),
trailing: trailing,
status: 'warning',
iconColor: healthIconColor,
iconBg: healthIconBg,
@@ -1626,72 +1631,74 @@ class ChatMessagesView extends ConsumerWidget {
}
// ── 3. 用药打卡 ──
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
const medIconColor = Color(0xFFEC4899);
const medIconBg = Color(0xFFFCE7F3);
reminders.whenOrNull(
data: (meds) {
if (meds.isNotEmpty) {
final overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.toList();
final upcomingMeds = meds
.where((m) => m['status'] == 'upcoming')
.toList();
final takenMeds = meds.where((m) => m['status'] == 'taken').toList();
// 未服/过期的在前面
for (final m in [...overdueMeds, ...upcomingMeds]) {
final name = m['name']?.toString() ?? '药品';
final time = m['scheduledTime']?.toString() ?? '';
final isOverdue = m['status'] == 'overdue';
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
name,
trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time',
status: isOverdue ? 'overdue' : 'pending',
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medCheckIn'),
),
);
}
// 已服的合并显示
if (takenMeds.isNotEmpty) {
final names = takenMeds
.map((m) => m['name']?.toString() ?? '药品')
.join('');
tasks.add(
_taskRow(
context,
Icons.check_circle,
names,
trailing: '已服用 (${takenMeds.length}项)',
status: 'done',
iconColor: medIconColor,
iconBg: medIconBg,
),
);
final overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.toList();
if (overdueMeds.isEmpty) return;
// 按过期时长排序——先把最严重的放前面
final now = DateTime.now();
final withDelta = overdueMeds.map((m) {
final timeStr = m['scheduledTime']?.toString() ?? '';
final parts = timeStr.split(':');
DateTime? scheduled;
if (parts.length >= 2) {
final h = int.tryParse(parts[0]);
final mn = int.tryParse(parts[1]);
if (h != null && mn != null) {
scheduled = DateTime(now.year, now.month, now.day, h, mn);
}
}
final overdueHours = scheduled == null
? 0
: now.difference(scheduled).inMinutes / 60.0;
return (m, overdueHours);
}).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
// 取最严重那一项作为代表,其他用"等"省略
final first = withDelta.first;
final firstName = first.$1['name']?.toString() ?? '药品';
final firstTime = first.$1['scheduledTime']?.toString() ?? '';
final firstHours = first.$2;
// 过期 < 1h 提醒服药1-4h 可补服(红色);>4h 已错过窗口(灰色提示但不强警告)
final String trailing;
final String status;
if (firstHours < 1) {
trailing = '$firstTime 该服药了';
status = 'pending';
} else if (firstHours < 4) {
trailing = '$firstTime 已漏服 ${firstHours.toStringAsFixed(0)} 小时,可补服';
status = 'overdue';
} else {
trailing = '$firstTime 已错过,请等下次按时服用';
status = 'overdue';
}
final title = overdueMeds.length == 1
? firstName
: '$firstName${overdueMeds.length}';
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
title,
trailing: trailing,
status: status,
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medCheckIn'),
),
);
},
);
final hasMeds =
reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty;
if (!hasMeds) {
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
'用药打卡',
trailing: '暂无用药提醒',
status: 'pending',
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medications'),
),
);
}
return Container(
margin: const EdgeInsets.only(bottom: 12),