- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用 - 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方 - 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧 - 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影 - 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整 - 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
590 lines
20 KiB
Dart
590 lines
20 KiB
Dart
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, 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: [
|
||
// ── 推送总开关 ──
|
||
_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 ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||
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: '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: 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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|