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/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../services/in_app_notification_service.dart'; 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 _service.acknowledge(item.id); 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); } 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: const Color(0xFFF6F8FC), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, scrolledUnderElevation: 0.5, 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.w800, 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: 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, boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.035), blurRadius: 14, offset: const Offset(0, 6), ), ], ), 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, 14), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), decoration: BoxDecoration( color: const Color(0xFFFFFBEB), borderRadius: AppRadius.mdBorder, border: Border.all(color: const Color(0xFFFDE68A)), ), child: Row( children: [ const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)), const SizedBox(width: 9), Text( '你有 $unreadCount 条未读消息', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: Color(0xFF92400E), ), ), ], ), ); } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); @override Widget build(BuildContext context) => _SectionShell( child: Row( children: [ const Icon( LucideIcons.calendarDays, size: 18, color: AppColors.primary, ), const SizedBox(width: 8), Text( text, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), ], ), ); } 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: [ const Icon( LucideIcons.history, size: 18, color: AppColors.textSecondary, ), const SizedBox(width: 8), Text( '$text · $count', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), const Spacer(), Text( expanded ? '收起' : '展开', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, color: AppColors.textSecondary, ), ), const SizedBox(width: 8), Icon( expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 22, color: AppColors.textSecondary, ), ], ), ), ); } 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( constraints: const BoxConstraints(minHeight: 94), color: Colors.white, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 13, 12, 12), child: Row( children: [ Stack( clipBehavior: Clip.none, children: [ Container( width: 48, height: 48, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ visual.color.withValues(alpha: 0.18), visual.lightColor, ], ), borderRadius: AppRadius.mdBorder, border: Border.all( color: visual.color.withValues(alpha: 0.22), ), ), child: Icon( visual.icon, size: 24, color: visual.color, ), ), if (!item.isRead) Positioned( top: -2, right: -2, child: Container( width: 10, height: 10, decoration: BoxDecoration( color: AppColors.error, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 1.5, ), ), ), ), ], ), const SizedBox(width: 13), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 3, ), decoration: BoxDecoration( color: visual.lightColor, borderRadius: AppRadius.pillBorder, ), child: Text( visual.label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w800, color: visual.color, ), ), ), const SizedBox(width: 8), Text( _formatTime(item.createdAt), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textHint, ), ), ], ), const SizedBox(height: 5), Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16, fontWeight: item.isRead ? FontWeight.w700 : FontWeight.w800, color: AppColors.textPrimary, height: 1.2, ), ), const SizedBox(height: 4), Text( item.message, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, height: 1.25, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), ], ), ), if (item.actionType != null) ...[ const SizedBox(width: 6), const Icon( Icons.chevron_right_rounded, size: 22, color: AppColors.textSecondary, ), ], ], ), ), if (showDivider) const Padding( padding: EdgeInsets.only(left: 75), 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) { switch (item.actionType) { case 'exercise': return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': return item.severity == 'critical' ? const _NotificationVisual( LucideIcons.triangleAlert, Color(0xFFEF4444), Color(0xFFFEE2E2), '紧急', ) : _NotificationVisual.fromModule(AppModuleVisuals.health); case 'report': return item.severity == 'error' ? const _NotificationVisual( LucideIcons.fileWarning, Color(0xFFEF4444), Color(0xFFFEE2E2), '报告', ) : _NotificationVisual.fromModule(AppModuleVisuals.report); default: return _NotificationVisual.fromModule(AppModuleVisuals.medication); } } } 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, 120, 28, 32), children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.borderLight), boxShadow: [AppTheme.shadowLight], ), child: Column( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( color: AppColors.primaryLight, borderRadius: BorderRadius.circular(18), ), child: Icon(icon, size: 32, color: AppColors.primary), ), const SizedBox(height: 20), Text( title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: const TextStyle( fontSize: 14, height: 1.5, color: AppColors.textSecondary, ), ), if (buttonText != null) ...[ const SizedBox(height: 22), FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric( horizontal: 28, vertical: 12, ), ), child: Text(buttonText!), ), ], ], ), ), ], ); }