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:
@@ -3,8 +3,10 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
// ── 通知偏好状态 ──
|
||||
// 持久化到后端 /api/notification-prefs,启动时自动拉取一次。
|
||||
|
||||
final notificationPrefsProvider =
|
||||
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||
@@ -12,28 +14,92 @@ final notificationPrefsProvider =
|
||||
);
|
||||
|
||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||
static const _defaults = {
|
||||
'pushEnabled': true,
|
||||
'medication': true,
|
||||
'healthAlert': true,
|
||||
'followUp': true,
|
||||
'aiReply': false,
|
||||
'dndEnabled': false,
|
||||
'dndStart': false,
|
||||
'dndEnd': false,
|
||||
// 健康录入提醒——总开关 + 5 子项
|
||||
'healthRecord': true,
|
||||
'healthRecord.bp': true,
|
||||
'healthRecord.hr': true,
|
||||
'healthRecord.glucose': true,
|
||||
'healthRecord.spo2': true,
|
||||
'healthRecord.weight': true,
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, bool> build() {
|
||||
// TODO: 从 SQLite 读取持久化值,此处先用默认值
|
||||
return {
|
||||
'medication': true,
|
||||
'healthAlert': true,
|
||||
'followUp': true,
|
||||
'aiReply': false,
|
||||
'dndEnabled': false,
|
||||
'pushEnabled': true,
|
||||
'dndStart': false, // 占位:实际用 TimeOfDay
|
||||
'dndEnd': false,
|
||||
};
|
||||
Future.microtask(_loadFromBackend);
|
||||
return {..._defaults};
|
||||
}
|
||||
|
||||
void toggle(String key) {
|
||||
state = {...state, key: !state[key]!};
|
||||
// TODO: 持久化到 SQLite
|
||||
Future<void> _loadFromBackend() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/notification-prefs');
|
||||
final data = res.data['data'] as Map?;
|
||||
if (data == null) return;
|
||||
state = {
|
||||
...state,
|
||||
'medication': (data['medicationReminder'] as bool?) ?? true,
|
||||
'healthAlert': (data['abnormalAlert'] as bool?) ?? true,
|
||||
'followUp': (data['followUpReminder'] as bool?) ?? true,
|
||||
'aiReply': (data['doctorReply'] as bool?) ?? false,
|
||||
'healthRecord': (data['healthRecordReminder'] as bool?) ?? true,
|
||||
'healthRecord.bp':
|
||||
(data['healthRecordReminderBloodPressure'] as bool?) ?? true,
|
||||
'healthRecord.hr':
|
||||
(data['healthRecordReminderHeartRate'] as bool?) ?? true,
|
||||
'healthRecord.glucose':
|
||||
(data['healthRecordReminderGlucose'] as bool?) ?? true,
|
||||
'healthRecord.spo2':
|
||||
(data['healthRecordReminderSpO2'] as bool?) ?? true,
|
||||
'healthRecord.weight':
|
||||
(data['healthRecordReminderWeight'] as bool?) ?? true,
|
||||
};
|
||||
} catch (_) {
|
||||
// 网络异常时保留默认值
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggle(String key) async {
|
||||
final newValue = !(state[key] ?? false);
|
||||
state = {...state, key: newValue};
|
||||
await _pushToBackend(key, newValue);
|
||||
}
|
||||
|
||||
Future<void> _pushToBackend(String key, bool value) async {
|
||||
// 把前端 key 映射到后端字段名
|
||||
String? backendField = switch (key) {
|
||||
'medication' => 'medicationReminder',
|
||||
'healthAlert' => 'abnormalAlert',
|
||||
'followUp' => 'followUpReminder',
|
||||
'aiReply' => 'doctorReply',
|
||||
'healthRecord' => 'healthRecordReminder',
|
||||
'healthRecord.bp' => 'healthRecordReminderBloodPressure',
|
||||
'healthRecord.hr' => 'healthRecordReminderHeartRate',
|
||||
'healthRecord.glucose' => 'healthRecordReminderGlucose',
|
||||
'healthRecord.spo2' => 'healthRecordReminderSpO2',
|
||||
'healthRecord.weight' => 'healthRecordReminderWeight',
|
||||
_ => null,
|
||||
};
|
||||
if (backendField == null) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.put('/api/notification-prefs', data: {backendField: value});
|
||||
} catch (_) {
|
||||
// 失败时回滚(避免界面与后端不同步)
|
||||
state = {...state, key: !value};
|
||||
}
|
||||
}
|
||||
|
||||
void setDndStart(TimeOfDay time) {
|
||||
state = {...state, 'dndStart': true}; // 简化存储
|
||||
state = {...state, 'dndStart': true};
|
||||
}
|
||||
|
||||
void setDndEnd(TimeOfDay time) {
|
||||
@@ -137,6 +203,58 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 健康录入提醒 ──
|
||||
_SectionTitle(title: '健康录入提醒'),
|
||||
_SwitchTile(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
iconBg: const Color(0xFFFEF3C7),
|
||||
iconColor: const Color(0xFFD97706),
|
||||
title: '每日录入提醒',
|
||||
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||
value: prefs['healthRecord'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord'),
|
||||
),
|
||||
if (prefs['healthRecord'] ?? true) ...[
|
||||
_SwitchTile(
|
||||
title: ' · 血压',
|
||||
value: prefs['healthRecord.bp'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.bp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 心率',
|
||||
value: prefs['healthRecord.hr'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.hr'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血糖',
|
||||
value: prefs['healthRecord.glucose'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.glucose'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血氧',
|
||||
value: prefs['healthRecord.spo2'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.spo2'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 体重',
|
||||
value: prefs['healthRecord.weight'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.weight'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 免打扰时段 ──
|
||||
_SectionTitle(title: '免打扰时段'),
|
||||
_SwitchTile(
|
||||
|
||||
Reference in New Issue
Block a user