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.new, ); class NotificationPrefsNotifier extends Notifier { @override NotificationPrefsViewState build() { final session = ref.watch(userSessionIdentityProvider); if (session != null) Future.microtask(load); return const NotificationPrefsViewState(loading: true); } Future 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 updateBool({ required String operationKey, required String backendField, required bool value, }) => _save(operationKey, {backendField: value}); Future setDndStart(TimeOfDay time) => _save('dndStart', {'dndStartMinutes': time.hour * 60 + time.minute}); Future setDndEnd(TimeOfDay time) => _save('dndEnd', {'dndEndMinutes': time.hour * 60 + time.minute}); Future saveHealthMetrics(Set metrics) { if (metrics.isEmpty) return Future.value('至少选择一项提醒指标'); return _save('healthMetrics', healthMetricUpdatePayload(metrics)); } Future _save( String operationKey, Map 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 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 save(Future 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 _showHealthMetricSheet( BuildContext context, Set selected, Future Function(Set) onSave, ) => showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => _HealthMetricSheet(selected: selected, onSave: onSave), ); class _HealthMetricSheet extends StatefulWidget { final Set selected; final Future Function(Set) onSave; const _HealthMetricSheet({required this.selected, required this.onSave}); @override State<_HealthMetricSheet> createState() => _HealthMetricSheetState(); } class _HealthMetricSheetState extends State<_HealthMetricSheet> { late final Set _draft = {...widget.selected}; bool _saving = false; String? _error; Future _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.w600, 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 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 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? 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? 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, ), ), ], ), ), ); }