import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../services/in_app_notification_service.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/common_widgets.dart'; Future acknowledgeBeforeNotificationOpen({ required Future Function() acknowledge, VoidCallback? onAcknowledged, required VoidCallback open, }) async { var acknowledged = true; try { await acknowledge(); onAcknowledged?.call(); } catch (_) { acknowledged = false; } open(); return acknowledged; } class NotificationCenterPage extends ConsumerStatefulWidget { const NotificationCenterPage({super.key}); @override ConsumerState createState() => _NotificationCenterPageState(); } class _NotificationCenterPageState extends ConsumerState { InAppNotificationHistory? _history; bool _loading = true; bool _markingAll = false; bool _showEarlier = false; String? _error; InAppNotificationService get _service => ref.read(inAppNotificationServiceProvider); @override void initState() { super.initState(); Future.microtask(_load); } Future _load() async { if (mounted) { setState(() { _loading = true; _error = null; }); } try { final history = await _service.getHistory(); if (!mounted) return; setState(() { _history = history; _loading = false; }); ref.invalidate(notificationUnreadCountProvider); } catch (error) { if (!mounted) return; setState(() { _error = '$error'; _loading = false; }); } } Future _open(InAppNotification item) async { if (!item.isRead) { await acknowledgeBeforeNotificationOpen( acknowledge: () => _service.acknowledge(item.id), onAcknowledged: () { if (!mounted) return; setState(() { final current = _history; if (current == null) return; _history = InAppNotificationHistory( unreadCount: (current.unreadCount - 1).clamp( 0, current.unreadCount, ), items: current.items .map( (value) => value.id == item.id ? value.copyWith(isRead: true) : value, ) .toList(), ); }); ref.invalidate(notificationUnreadCountProvider); }, open: () { if (mounted) _openTarget(item); }, ); return; } _openTarget(item); } void _openTarget(InAppNotification item) { if (!mounted || item.actionType == null) return; switch (item.actionType) { case 'medication': pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''}); return; case 'exercise': pushRoute(ref, 'exercisePlan'); return; case 'health': pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''}); return; case 'report': final reportId = item.actionTargetId; if (reportId != null && reportId.isNotEmpty) { pushRoute(ref, 'aiAnalysis', params: {'id': reportId}); } return; } } Future _markAllRead() async { if (_markingAll || (_history?.unreadCount ?? 0) == 0) return; setState(() => _markingAll = true); try { await _service.markAllRead(); if (!mounted) return; final current = _history; if (current != null) { setState( () => _history = InAppNotificationHistory( unreadCount: 0, items: current.items .map((item) => item.copyWith(isRead: true)) .toList(), ), ); } ref.invalidate(notificationUnreadCountProvider); } finally { if (mounted) setState(() => _markingAll = false); } } Future _delete(InAppNotification item) async { try { await _service.delete(item.id); if (!mounted) return; final current = _history; if (current != null) { setState( () => _history = InAppNotificationHistory( unreadCount: item.isRead ? current.unreadCount : (current.unreadCount - 1).clamp(0, current.unreadCount), items: current.items.where((value) => value.id != item.id).toList(), ), ); } ref.invalidate(notificationUnreadCountProvider); } catch (_) { if (mounted) await _load(); } } @override Widget build(BuildContext context) { final items = _history?.items ?? const []; final unread = _history?.unreadCount ?? 0; return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: Colors.white, elevation: 0, scrolledUnderElevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref), ), title: const Text( '通知中心', style: TextStyle( fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), centerTitle: true, actions: [ if (unread > 0) TextButton( onPressed: _markingAll ? null : _markAllRead, child: _markingAll ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text( '全部已读', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), ), IconButton( tooltip: '通知设置', icon: const Icon(Icons.settings_outlined), onPressed: () => pushRoute(ref, 'notificationPrefs'), ), const SizedBox(width: 4), ], ), body: ColoredBox( color: AppColors.background, child: SizedBox.expand( child: RefreshIndicator( onRefresh: _load, color: AppColors.primary, child: _buildBody(items, unread), ), ), ), ); } Widget _buildBody(List items, int unread) { if (_loading) return const Center(child: CircularProgressIndicator()); if (_error != null) { return _MessageState( icon: LucideIcons.wifiOff, title: '通知加载失败', message: '请检查网络连接后重试', buttonText: '重新加载', onPressed: _load, ); } if (items.isEmpty) { return const _MessageState( icon: LucideIcons.bellOff, title: '暂时没有通知', message: '用药、运动、健康指标和报告消息会显示在这里', ); } final today = []; final earlier = []; final now = DateTime.now(); for (final item in items) { final local = item.createdAt.toLocal(); final isToday = local.year == now.year && local.month == now.month && local.day == now.day; (isToday ? today : earlier).add(item); } return ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 14, 16, 32), children: [ if (unread > 0) _UnreadHint(unreadCount: unread), if (today.isNotEmpty) ...[ const _SectionTitle('今天'), const SizedBox(height: 10), _NotificationGroup( children: [ for (var i = 0; i < today.length; i++) _buildDismissible(today[i], showDivider: i < today.length - 1), ], ), ], if (earlier.isNotEmpty) ...[ if (today.isNotEmpty) const SizedBox(height: 18), _CollapsibleSectionTitle( text: '更早', count: earlier.length, expanded: _showEarlier, onTap: () => setState(() => _showEarlier = !_showEarlier), ), const SizedBox(height: 10), if (_showEarlier) _NotificationGroup( children: [ for (var i = 0; i < earlier.length; i++) _buildDismissible( earlier[i], showDivider: i < earlier.length - 1, ), ], ), ], ], ); } Widget _buildDismissible( InAppNotification item, { required bool showDivider, }) => Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 26), color: AppColors.error, child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.trash2, color: Colors.white, size: 22), SizedBox(width: 8), Text( '删除', style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700, ), ), ], ), ), onDismissed: (_) => _delete(item), child: _NotificationRow( item: item, showDivider: showDivider, onTap: () => _open(item), ), ); } class _NotificationGroup extends StatelessWidget { final List children; const _NotificationGroup({required this.children}); @override Widget build(BuildContext context) { return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, ), child: Column(children: children), ); } } class _UnreadHint extends StatelessWidget { final int unreadCount; const _UnreadHint({required this.unreadCount}); @override Widget build(BuildContext context) => Container( margin: const EdgeInsets.fromLTRB(0, 2, 0, 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.mdBorder, ), child: Row( children: [ const Icon( LucideIcons.bellRing, size: 17, color: AppColors.textSecondary, ), const SizedBox(width: 8), RichText( text: TextSpan( style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), children: [ const TextSpan(text: '还有 '), TextSpan( text: '$unreadCount', style: const TextStyle( fontWeight: FontWeight.w600, color: AppColors.errorText, ), ), const TextSpan(text: ' 条未读消息'), ], ), ), ], ), ); } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); @override Widget build(BuildContext context) => _SectionShell( child: Text( text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textHint, letterSpacing: 0, ), ), ); } class _SectionShell extends StatelessWidget { final Widget child; const _SectionShell({required this.child}); @override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.fromLTRB(2, 12, 2, 8), child: child); } class _CollapsibleSectionTitle extends StatelessWidget { final String text; final int count; final bool expanded; final VoidCallback onTap; const _CollapsibleSectionTitle({ required this.text, required this.count, required this.expanded, required this.onTap, }); @override Widget build(BuildContext context) => GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: _SectionShell( child: Row( children: [ Text( '$text · $count', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textHint, letterSpacing: 0, ), ), const Spacer(), Text( expanded ? '收起' : '展开', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textHint, ), ), const SizedBox(width: 4), Icon( expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: AppColors.textHint, ), ], ), ), ); } class _NotificationRow extends StatelessWidget { final InAppNotification item; final bool showDivider; final VoidCallback onTap; const _NotificationRow({ required this.item, required this.showDivider, required this.onTap, }); @override Widget build(BuildContext context) { final visual = _NotificationVisual.of(item); return Material( color: Colors.white, child: InkWell( onTap: onTap, child: Container( color: Colors.white, child: Column( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: BoxConstraints(minHeight: showDivider ? 81 : 82), child: Padding( padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: visual.lightColor, borderRadius: AppRadius.smBorder, ), child: Icon(visual.icon, size: 21, color: visual.color), ), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded( child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15, fontWeight: item.isRead ? FontWeight.w600 : FontWeight.w700, color: AppColors.textPrimary, height: 1.2, ), ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: visual.lightColor, borderRadius: AppRadius.pillBorder, ), child: Text( visual.label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: visual.color, ), ), ), ], ), const SizedBox(height: 4), Text( item.message, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, height: 1.2, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), ], ), ), const SizedBox(width: 8), SizedBox( width: 68, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (item.isRead) const SizedBox(height: 8) else Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.error, shape: BoxShape.circle, ), ), const SizedBox(height: 7), Text( _formatTime(item.createdAt), maxLines: 1, textAlign: TextAlign.right, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textHint, ), ), ], ), ), ], ), ), ), if (showDivider) const Padding( padding: EdgeInsets.only(left: 65), child: Divider( height: 1, thickness: 0.7, color: Color(0xFFE8ECF2), ), ), ], ), ), ), ); } static String _formatTime(DateTime value) { final local = value.toLocal(); final now = DateTime.now(); final time = '${local.hour.toString().padLeft(2, '0')}:' '${local.minute.toString().padLeft(2, '0')}'; if (local.year == now.year && local.month == now.month && local.day == now.day) { return time; } if (local.year == now.year) return '${local.month}-${local.day} $time'; return '${local.year}-${local.month}-${local.day} $time'; } } class _NotificationVisual { final IconData icon; final Color color; final Color lightColor; final String label; const _NotificationVisual(this.icon, this.color, this.lightColor, this.label); factory _NotificationVisual.fromModule(AppModuleVisual visual) { return _NotificationVisual( visual.icon, visual.color, visual.lightColor, visual.label, ); } factory _NotificationVisual.of(InAppNotification item) { final kind = item.actionType ?? item.type; switch (kind) { case 'exercise': return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': return item.severity == 'critical' ? const _NotificationVisual( LucideIcons.triangleAlert, AppColors.errorText, AppColors.errorLight, '紧急', ) : _NotificationVisual.fromModule(AppModuleVisuals.health); case 'report': return item.severity == 'error' ? const _NotificationVisual( LucideIcons.fileWarning, AppColors.errorText, AppColors.errorLight, '报告', ) : _NotificationVisual.fromModule(AppModuleVisuals.report); case 'medication': return _NotificationVisual.fromModule(AppModuleVisuals.medication); case 'diet': return _NotificationVisual.fromModule(AppModuleVisuals.diet); default: return _NotificationVisual.fromModule(AppModuleVisuals.notification); } } } class _MessageState extends StatelessWidget { final IconData icon; final String title; final String message; final String? buttonText; final VoidCallback? onPressed; const _MessageState({ required this.icon, required this.title, required this.message, this.buttonText, this.onPressed, }); @override Widget build(BuildContext context) => ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(28, 100, 28, 32), children: [ AppEmptyState( icon: icon, iconColor: onPressed == null ? AppColors.notification : AppColors.error, title: title, subtitle: message, padding: const EdgeInsets.all(24), action: buttonText == null ? null : SizedBox( width: 140, child: AppGradientOutlineButton( label: buttonText!, onPressed: onPressed, ), ), ), ], ); }