后端 - NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重) - EF 迁移 AddHealthRecordReminder - 新增 GET/PUT /api/notification-prefs - 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒 前端 - 通知偏好对接真实后端 API - 设置页新增「健康录入提醒」总开关+5 子项 - 通知中心右上角加齿轮跳设置页 - 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案 - 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变 - 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变 - 设置页简化(去掉分组/彩色图标方块) - 健康档案保存按钮移到右上角,TextButton 全局默认黑色 - API baseUrl 跟随网络环境
481 lines
16 KiB
Dart
481 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
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>>(
|
||
NotificationPrefsNotifier.new,
|
||
);
|
||
|
||
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() {
|
||
Future.microtask(_loadFromBackend);
|
||
return {..._defaults};
|
||
}
|
||
|
||
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};
|
||
}
|
||
|
||
void setDndEnd(TimeOfDay time) {
|
||
state = {...state, 'dndEnd': true};
|
||
}
|
||
}
|
||
|
||
// ── 页面 ──
|
||
|
||
class NotificationPrefsPage extends ConsumerWidget {
|
||
const NotificationPrefsPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final prefs = ref.watch(notificationPrefsProvider);
|
||
final dndOn = prefs['dndEnabled'] ?? false;
|
||
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.transparent,
|
||
elevation: 0,
|
||
leading: IconButton(
|
||
icon: const Icon(
|
||
Icons.arrow_back_ios_new,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
title: Text(
|
||
'消息通知',
|
||
style: TextStyle(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
centerTitle: true,
|
||
),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── 推送总开关 ──
|
||
_SectionTitle(title: '推送通知'),
|
||
_SwitchTile(
|
||
title: '允许推送通知',
|
||
subtitle: '关闭后将不再收到任何系统推送',
|
||
value: prefs['pushEnabled'] ?? true,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('pushEnabled'),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ── 各类通知开关 ──
|
||
_SectionTitle(title: '通知类型'),
|
||
_SwitchTile(
|
||
icon: Icons.medication_rounded,
|
||
iconBg: AppColors.errorLight,
|
||
iconColor: AppColors.error,
|
||
title: '用药提醒',
|
||
subtitle: '服药时间到达时提醒您',
|
||
value: prefs['medication'] ?? true,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('medication'),
|
||
),
|
||
_SwitchTile(
|
||
icon: Icons.warning_amber_rounded,
|
||
iconBg: AppColors.errorLight,
|
||
iconColor: AppTheme.error,
|
||
title: '健康异常提醒',
|
||
subtitle: '检测到数据异常时及时通知',
|
||
value: prefs['healthAlert'] ?? true,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('healthAlert'),
|
||
),
|
||
_SwitchTile(
|
||
icon: Icons.event_available_rounded,
|
||
iconBg: AppColors.successLight,
|
||
iconColor: AppColors.success,
|
||
title: '复查日期提醒',
|
||
subtitle: '复查日前一天提醒您预约',
|
||
value: prefs['followUp'] ?? true,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('followUp'),
|
||
),
|
||
_SwitchTile(
|
||
icon: Icons.forum_outlined,
|
||
iconBg: AppColors.iconBg,
|
||
iconColor: AppTheme.primary,
|
||
title: 'AI 回复通知',
|
||
subtitle: 'AI 助手回复时发送通知',
|
||
value: prefs['aiReply'] ?? false,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('aiReply'),
|
||
),
|
||
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(
|
||
title: '开启免打扰模式',
|
||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||
value: dndOn,
|
||
onChanged: (v) => ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.toggle('dndEnabled'),
|
||
),
|
||
if (dndOn) ...[
|
||
Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 14,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.surface,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: _TimeButton(
|
||
label: '开始',
|
||
time: '22:00',
|
||
onTap: () async {
|
||
final picked = await showAppTimePicker(
|
||
context,
|
||
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
||
);
|
||
if (picked != null && context.mounted) {
|
||
ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.setDndStart(picked);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Text(
|
||
'~',
|
||
style: TextStyle(
|
||
fontSize: 19,
|
||
color: AppTheme.textHint,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _TimeButton(
|
||
label: '结束',
|
||
time: '08:00',
|
||
onTap: () async {
|
||
final picked = await showAppTimePicker(
|
||
context,
|
||
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||
);
|
||
if (picked != null && context.mounted) {
|
||
ref
|
||
.read(notificationPrefsProvider.notifier)
|
||
.setDndEnd(picked);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
const SizedBox(height: 40),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 子组件 ──
|
||
|
||
class _SectionTitle extends StatelessWidget {
|
||
final String title;
|
||
const _SectionTitle({required this.title});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||
child: Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SwitchTile extends StatelessWidget {
|
||
final IconData? icon;
|
||
final Color? iconBg;
|
||
final Color? iconColor;
|
||
final String title;
|
||
final String? subtitle;
|
||
final bool value;
|
||
final ValueChanged<bool> onChanged;
|
||
|
||
const _SwitchTile({
|
||
this.icon,
|
||
this.iconBg,
|
||
this.iconColor,
|
||
required this.title,
|
||
this.subtitle,
|
||
required this.value,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.surface,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
if (icon != null) ...[
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: iconBg ?? AppColors.iconBg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
size: 23,
|
||
color: iconColor ?? AppColors.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
],
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
color: AppColors.textPrimary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
if (subtitle != null && subtitle!.isNotEmpty)
|
||
Text(
|
||
subtitle!,
|
||
style: TextStyle(fontSize: 15, color: AppTheme.textSub),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Switch(
|
||
value: value,
|
||
onChanged: onChanged,
|
||
activeThumbColor: AppTheme.primary,
|
||
activeTrackColor: AppColors.primaryLight,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TimeButton extends StatelessWidget {
|
||
final String label;
|
||
final String time;
|
||
final VoidCallback onTap;
|
||
const _TimeButton({
|
||
required this.label,
|
||
required this.time,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: AppColors.border),
|
||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(fontSize: 14, color: AppTheme.textHint),
|
||
),
|
||
Text(
|
||
time,
|
||
style: const TextStyle(
|
||
fontSize: 19,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppTheme.primary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|