Files
AI-Health/health_app/lib/pages/settings/notification_prefs_page.dart
MingNian e654c1e0cc feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
2026-07-13 10:39:34 +08:00

615 lines
21 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 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.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, dynamic>>(
NotificationPrefsNotifier.new,
);
class NotificationPrefsNotifier extends Notifier<Map<String, dynamic>> {
static const _defaults = {
'pushEnabled': true,
'medication': true,
'healthAlert': true,
'followUp': true,
'aiReply': false,
'dndEnabled': false,
'dndStartMinutes': 22 * 60,
'dndEndMinutes': 8 * 60,
// 健康录入提醒——总开关 + 5 子项
'healthRecord': true,
'healthRecord.bp': true,
'healthRecord.hr': true,
'healthRecord.glucose': true,
'healthRecord.spo2': true,
'healthRecord.weight': true,
};
@override
Map<String, dynamic> 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,
'pushEnabled': (data['pushEnabled'] as bool?) ?? true,
'dndEnabled': (data['dndEnabled'] as bool?) ?? false,
'dndStartMinutes':
(data['dndStartMinutes'] as num?)?.toInt() ?? 22 * 60,
'dndEndMinutes': (data['dndEndMinutes'] as num?)?.toInt() ?? 8 * 60,
'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',
'pushEnabled' => 'pushEnabled',
'dndEnabled' => 'dndEnabled',
_ => 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};
}
}
Future<void> setDndStart(TimeOfDay time) async {
await _setTime('dndStartMinutes', time.hour * 60 + time.minute);
}
Future<void> setDndEnd(TimeOfDay time) async {
await _setTime('dndEndMinutes', time.hour * 60 + time.minute);
}
Future<void> _setTime(String key, int minutes) async {
final previous = state[key];
state = {...state, key: minutes};
try {
await ref
.read(apiClientProvider)
.put('/api/notification-prefs', data: {key: minutes});
} catch (_) {
state = {...state, key: previous};
}
}
}
// ── 页面 ──
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(notificationPrefsProvider);
final dndOn = prefs['dndEnabled'] as bool? ?? false;
final dndStart = prefs['dndStartMinutes'] as int? ?? 22 * 60;
final dndEnd = prefs['dndEndMinutes'] as int? ?? 8 * 60;
String formatMinutes(int value) =>
'${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}';
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: [
// ── 推送总开关 ──
_SettingsListSection(
title: '推送通知',
children: [
_SwitchTile(
title: '允许推送通知',
subtitle: '关闭后将不再收到任何系统推送',
value: prefs['pushEnabled'] ?? true,
showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('pushEnabled'),
),
],
),
const SizedBox(height: 24),
// ── 各类通知开关 ──
_SettingsListSection(
title: '通知类型',
children: [
_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,
showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('aiReply'),
),
],
),
const SizedBox(height: 24),
// ── 健康录入提醒 ──
_SettingsListSection(
title: '健康录入提醒',
children: [
_SwitchTile(
icon: Icons.alarm_on_rounded,
iconBg: const Color(0xFFFEF3C7),
iconColor: const Color(0xFFD97706),
title: '每日录入提醒',
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
value: prefs['healthRecord'] ?? true,
showDivider: 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,
showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.weight'),
),
],
],
),
const SizedBox(height: 24),
// ── 免打扰时段 ──
_SettingsListSection(
title: '免打扰时段',
children: [
_SwitchTile(
title: '开启免打扰模式',
subtitle: dndOn
? '${formatMinutes(dndStart)} - ${formatMinutes(dndEnd)} 期间静音'
: '关闭后全天接收通知',
value: dndOn,
showDivider: dndOn,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('dndEnabled'),
),
if (dndOn)
Container(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(color: AppTheme.surface),
child: Row(
children: [
Expanded(
child: _TimeButton(
label: '开始',
time: formatMinutes(dndStart),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: TimeOfDay(
hour: dndStart ~/ 60,
minute: dndStart % 60,
),
);
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: formatMinutes(dndEnd),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: TimeOfDay(
hour: dndEnd ~/ 60,
minute: dndEnd % 60,
),
);
if (picked != null && context.mounted) {
ref
.read(notificationPrefsProvider.notifier)
.setDndEnd(picked);
}
},
),
),
],
),
),
],
),
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 _SettingsListSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _SettingsListSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(title: title),
ClipRRect(
borderRadius: AppRadius.mdBorder,
child: ColoredBox(
color: AppTheme.surface,
child: Column(children: children),
),
),
],
);
}
}
class _SwitchTile extends StatelessWidget {
final IconData? icon;
final Color? iconBg;
final Color? iconColor;
final String title;
final String? subtitle;
final bool value;
final bool showDivider;
final ValueChanged<bool> onChanged;
const _SwitchTile({
this.icon,
this.iconBg,
this.iconColor,
required this.title,
this.subtitle,
required this.value,
this.showDivider = true,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final dense = icon == null && (subtitle == null || subtitle!.isEmpty);
return Container(
margin: EdgeInsets.zero,
decoration: BoxDecoration(color: AppTheme.surface),
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 14,
vertical: dense ? 8 : 14,
),
child: Row(
children: [
if (icon != null) ...[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: iconBg ?? AppColors.iconBg,
borderRadius: AppRadius.smBorder,
),
child: Icon(
icon,
size: 20,
color: iconColor ?? AppColors.primary,
),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: dense ? 15 : 16,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null && subtitle!.isNotEmpty)
const SizedBox(height: 2),
if (subtitle != null && subtitle!.isNotEmpty)
Text(
subtitle!,
style: TextStyle(
fontSize: 13,
color: AppTheme.textSub,
),
),
],
),
),
_AppSwitch(value: value, onChanged: onChanged),
],
),
),
if (showDivider)
Padding(
padding: EdgeInsets.only(left: icon != null ? 60 : 14),
child: const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
),
);
}
}
class _AppSwitch extends StatelessWidget {
final bool value;
final ValueChanged<bool> onChanged;
const _AppSwitch({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final trackColor = value ? AppColors.primary : Colors.white;
final borderColor = value
? AppColors.primary.withValues(alpha: 0.38)
: AppColors.border;
return Semantics(
button: true,
toggled: value,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: 46,
height: 28,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: trackColor,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: borderColor, width: 1),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: value ? Colors.white : AppColors.textHint,
shape: BoxShape.circle,
),
),
),
),
),
);
}
}
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: AppRadius.mdBorder,
),
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,
),
),
],
),
),
);
}
}