Files
AI-Health/health_app/lib/pages/settings/notification_prefs_page.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

866 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/api_client.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
import 'notification_prefs_logic.dart';
final notificationPrefsProvider =
NotifierProvider<NotificationPrefsNotifier, NotificationPrefsViewState>(
NotificationPrefsNotifier.new,
);
class NotificationPrefsNotifier extends Notifier<NotificationPrefsViewState> {
@override
NotificationPrefsViewState build() {
Future.microtask(load);
return const NotificationPrefsViewState(loading: true);
}
Future<void> load() async {
state = state.copyWith(loading: true, clearLoadError: true);
try {
final response = await ref
.read(apiClientProvider)
.get('/api/notification-prefs');
final data = response.data['data'];
if (data is! Map) throw const ApiException('服务器返回的通知设置无效');
state = NotificationPrefsViewState(
prefs: NotificationPrefs.fromJson(data),
);
} catch (error) {
state = NotificationPrefsViewState(loadError: _message(error));
}
}
Future<String?> updateBool({
required String operationKey,
required String backendField,
required bool value,
}) => _save(operationKey, {backendField: value});
Future<String?> setDndStart(TimeOfDay time) =>
_save('dndStart', {'dndStartMinutes': time.hour * 60 + time.minute});
Future<String?> setDndEnd(TimeOfDay time) =>
_save('dndEnd', {'dndEndMinutes': time.hour * 60 + time.minute});
Future<String?> saveHealthMetrics(Set<HealthReminderMetric> metrics) {
if (metrics.isEmpty) return Future.value('至少选择一项提醒指标');
return _save('healthMetrics', healthMetricUpdatePayload(metrics));
}
Future<String?> _save(
String operationKey,
Map<String, dynamic> payload,
) async {
if (state.prefs == null || state.savingKeys.contains(operationKey)) {
return null;
}
final saving = {...state.savingKeys, operationKey};
state = state.copyWith(savingKeys: saving);
try {
final response = await ref
.read(apiClientProvider)
.put('/api/notification-prefs', data: payload);
final data = response.data['data'];
if (data is! Map) throw const ApiException('服务器未返回最新通知设置');
state = NotificationPrefsViewState(
prefs: NotificationPrefs.fromJson(data),
savingKeys: {...saving}..remove(operationKey),
);
return null;
} catch (error) {
state = state.copyWith(savingKeys: {...saving}..remove(operationKey));
return _message(error);
}
}
String _message(Object error) =>
error is ApiException ? error.message : '保存失败,请检查网络后重试';
}
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewState = ref.watch(notificationPrefsProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: AppColors.textPrimary,
),
onPressed: () => popRoute(ref),
),
title: const Text(
'消息通知',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
centerTitle: true,
),
body: viewState.loading
? const Center(child: CircularProgressIndicator(strokeWidth: 2))
: viewState.loadError != null
? AppErrorState(
title: '通知设置加载失败',
subtitle: viewState.loadError!,
onRetry: () =>
ref.read(notificationPrefsProvider.notifier).load(),
)
: _PreferencesContent(
prefs: viewState.prefs!,
savingKeys: viewState.savingKeys,
),
);
}
}
class _PreferencesContent extends ConsumerWidget {
final NotificationPrefs prefs;
final Set<String> savingKeys;
const _PreferencesContent({required this.prefs, required this.savingKeys});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(notificationPrefsProvider.notifier);
String formatMinutes(int value) =>
'${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}';
Future<void> save(Future<String?> operation) async {
final error = await operation;
if (error != null && context.mounted) {
AppToast.show(context, error, type: AppToastType.error);
}
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsListSection(
title: '推送通知',
children: [
_SwitchTile(
title: '允许推送通知',
subtitle: '关闭后将不再收到任何系统通知',
value: prefs.pushEnabled,
saving: savingKeys.contains('pushEnabled'),
showDivider: false,
onChanged: savingKeys.contains('pushEnabled')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'pushEnabled',
backendField: 'pushEnabled',
value: value,
),
),
),
],
),
const SizedBox(height: 24),
_SettingsListSection(
title: '通知类型',
children: [
_SwitchTile(
icon: AppModuleVisuals.medication.icon,
iconBg: AppModuleVisuals.medication.lightColor,
iconColor: AppModuleVisuals.medication.color,
title: '用药提醒',
subtitle: '服药时间到达时提醒您',
value: prefs.medicationReminder,
saving: savingKeys.contains('medication'),
onChanged: savingKeys.contains('medication')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'medication',
backendField: 'medicationReminder',
value: value,
),
),
),
_SwitchTile(
icon: LucideIcons.triangleAlert,
iconBg: AppModuleVisuals.health.lightColor,
iconColor: AppModuleVisuals.health.color,
title: '健康异常提醒',
subtitle: '检测到数据异常时及时通知',
value: prefs.abnormalAlert,
saving: savingKeys.contains('healthAlert'),
onChanged: savingKeys.contains('healthAlert')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'healthAlert',
backendField: 'abnormalAlert',
value: value,
),
),
),
_SwitchTile(
icon: AppModuleVisuals.followup.icon,
iconBg: AppModuleVisuals.followup.lightColor,
iconColor: AppModuleVisuals.followup.color,
title: '复查日期提醒',
subtitle: '复查日前提醒您安排就医',
value: prefs.followUpReminder,
saving: savingKeys.contains('followUp'),
onChanged: savingKeys.contains('followUp')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'followUp',
backendField: 'followUpReminder',
value: value,
),
),
),
_SwitchTile(
icon: AppModuleVisuals.ai.icon,
iconBg: AppModuleVisuals.ai.lightColor,
iconColor: AppModuleVisuals.ai.color,
title: 'AI 回复通知',
subtitle: 'AI 助手回复时发送通知',
value: prefs.doctorReply,
saving: savingKeys.contains('aiReply'),
showDivider: false,
onChanged: savingKeys.contains('aiReply')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'aiReply',
backendField: 'doctorReply',
value: value,
),
),
),
],
),
const SizedBox(height: 24),
_SettingsListSection(
title: '健康录入提醒',
children: [
_SwitchTile(
icon: LucideIcons.alarmClock,
iconBg: AppModuleVisuals.health.lightColor,
iconColor: AppModuleVisuals.health.color,
title: '每日录入提醒',
subtitle: '仅提醒当天尚未录入的健康指标',
value: prefs.healthRecordReminder,
saving: savingKeys.contains('healthRecord'),
onChanged: savingKeys.contains('healthRecord')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'healthRecord',
backendField: 'healthRecordReminder',
value: value,
),
),
),
_ActionTile(
icon: LucideIcons.slidersHorizontal,
title: '提醒指标',
subtitle: healthMetricSummary(prefs.enabledHealthMetrics),
enabled:
prefs.healthRecordReminder &&
!savingKeys.contains('healthMetrics'),
saving: savingKeys.contains('healthMetrics'),
onTap: () => _showHealthMetricSheet(
context,
prefs.enabledHealthMetrics,
notifier.saveHealthMetrics,
),
),
],
),
const SizedBox(height: 24),
_SettingsListSection(
title: '免打扰时段',
children: [
_SwitchTile(
title: '开启免打扰模式',
subtitle: prefs.dndEnabled
? '${formatMinutes(prefs.dndStartMinutes)} - ${formatMinutes(prefs.dndEndMinutes)} 期间静音'
: '关闭后全天接收通知',
value: prefs.dndEnabled,
saving: savingKeys.contains('dndEnabled'),
showDivider: prefs.dndEnabled,
onChanged: savingKeys.contains('dndEnabled')
? null
: (value) => save(
notifier.updateBool(
operationKey: 'dndEnabled',
backendField: 'dndEnabled',
value: value,
),
),
),
if (prefs.dndEnabled)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
color: AppTheme.surface,
child: Row(
children: [
Expanded(
child: _TimeButton(
label: '开始',
time: formatMinutes(prefs.dndStartMinutes),
saving: savingKeys.contains('dndStart'),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: TimeOfDay(
hour: prefs.dndStartMinutes ~/ 60,
minute: prefs.dndStartMinutes % 60,
),
);
if (picked != null && context.mounted) {
await save(notifier.setDndStart(picked));
}
},
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'~',
style: TextStyle(
fontSize: 19,
color: AppColors.textHint,
),
),
),
Expanded(
child: _TimeButton(
label: '结束',
time: formatMinutes(prefs.dndEndMinutes),
saving: savingKeys.contains('dndEnd'),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: TimeOfDay(
hour: prefs.dndEndMinutes ~/ 60,
minute: prefs.dndEndMinutes % 60,
),
);
if (picked != null && context.mounted) {
await save(notifier.setDndEnd(picked));
}
},
),
),
],
),
),
],
),
const SizedBox(height: 40),
],
),
);
}
}
Future<void> _showHealthMetricSheet(
BuildContext context,
Set<HealthReminderMetric> selected,
Future<String?> Function(Set<HealthReminderMetric>) onSave,
) => showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _HealthMetricSheet(selected: selected, onSave: onSave),
);
class _HealthMetricSheet extends StatefulWidget {
final Set<HealthReminderMetric> selected;
final Future<String?> Function(Set<HealthReminderMetric>) onSave;
const _HealthMetricSheet({required this.selected, required this.onSave});
@override
State<_HealthMetricSheet> createState() => _HealthMetricSheetState();
}
class _HealthMetricSheetState extends State<_HealthMetricSheet> {
late final Set<HealthReminderMetric> _draft = {...widget.selected};
bool _saving = false;
String? _error;
Future<void> _save() async {
if (_draft.isEmpty) {
setState(() => _error = '至少选择一项提醒指标');
return;
}
setState(() {
_saving = true;
_error = null;
});
final error = await widget.onSave(_draft);
if (!mounted) return;
if (error == null) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_error = error;
});
}
@override
Widget build(BuildContext context) => Container(
padding: EdgeInsets.fromLTRB(
20,
12,
20,
18 + MediaQuery.viewInsetsOf(context).bottom,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 38,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.pillBorder,
),
),
),
const SizedBox(height: 18),
const Text(
'选择提醒指标',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 5),
const Text(
'当天未录入已选指标时,小脉会发送提醒。',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 14),
for (final metric in HealthReminderMetric.values)
_MetricCheckbox(
metric: metric,
selected: _draft.contains(metric),
enabled: !_saving,
onChanged: (selected) {
setState(() {
selected ? _draft.add(metric) : _draft.remove(metric);
_error = null;
});
},
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.error,
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? '保存中...' : '保存'),
),
),
],
),
);
}
class _MetricCheckbox extends StatelessWidget {
final HealthReminderMetric metric;
final bool selected;
final bool enabled;
final ValueChanged<bool> onChanged;
const _MetricCheckbox({
required this.metric,
required this.selected,
required this.enabled,
required this.onChanged,
});
@override
Widget build(BuildContext context) => InkWell(
onTap: enabled ? () => onChanged(!selected) : null,
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Checkbox(
value: selected,
onChanged: enabled ? (value) => onChanged(value ?? false) : null,
),
const SizedBox(width: 8),
Text(
metric.label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
);
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) => 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) => 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 saving;
final bool showDivider;
final ValueChanged<bool>? onChanged;
const _SwitchTile({
this.icon,
this.iconBg,
this.iconColor,
required this.title,
this.subtitle,
required this.value,
this.saving = false,
this.showDivider = true,
required this.onChanged,
});
@override
Widget build(BuildContext context) => _SettingsRow(
icon: icon,
iconBg: iconBg,
iconColor: iconColor,
title: title,
subtitle: subtitle,
showDivider: showDivider,
trailing: saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _AppSwitch(value: value, onChanged: onChanged),
);
}
class _ActionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool enabled;
final bool saving;
final VoidCallback onTap;
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.enabled,
required this.saving,
required this.onTap,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
child: _SettingsRow(
icon: icon,
iconBg: AppColors.iconBg,
iconColor: AppColors.primary,
title: title,
subtitle: subtitle,
showDivider: false,
muted: !enabled,
trailing: saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(
Icons.chevron_right_rounded,
color: AppColors.textHint,
),
),
),
);
}
class _SettingsRow extends StatelessWidget {
final IconData? icon;
final Color? iconBg;
final Color? iconColor;
final String title;
final String? subtitle;
final Widget trailing;
final bool showDivider;
final bool muted;
const _SettingsRow({
this.icon,
this.iconBg,
this.iconColor,
required this.title,
this.subtitle,
required this.trailing,
required this.showDivider,
this.muted = false,
});
@override
Widget build(BuildContext context) => Opacity(
opacity: muted ? 0.48 : 1,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 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: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
if (subtitle?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
],
),
),
const SizedBox(width: 10),
trailing,
],
),
),
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) => Semantics(
button: true,
toggled: value,
enabled: onChanged != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChanged == null ? null : () => onChanged!(!value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: 46,
height: 28,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: value ? AppColors.primary : Colors.white,
borderRadius: AppRadius.pillBorder,
border: Border.all(
color: value
? AppColors.primary.withValues(alpha: 0.38)
: AppColors.border,
),
),
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 bool saving;
final VoidCallback onTap;
const _TimeButton({
required this.label,
required this.time,
required this.saving,
required this.onTap,
});
@override
Widget build(BuildContext context) => InkWell(
onTap: saving ? null : onTap,
borderRadius: AppRadius.mdBorder,
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: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
if (saving)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
time,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
],
),
),
);
}