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_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; 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) { 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.w700, color: AppColors.textPrimary, ), ), centerTitle: true, actions: [ if (unread > 0) Padding( padding: const EdgeInsets.only(right: 4), child: TextButton( onPressed: _markingAll ? null : _markAllRead, style: TextButton.styleFrom( foregroundColor: AppColors.textPrimary, padding: const EdgeInsets.symmetric(horizontal: 10), ), child: _markingAll ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.textPrimary, ), ) : const Text( '全部已读', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), IconButton( tooltip: '通知设置', icon: const Icon(Icons.settings_outlined, color: AppColors.textPrimary), onPressed: () => pushRoute(ref, 'notificationPrefs'), ), const SizedBox(width: 4), ], ), body: RefreshIndicator( onRefresh: _load, color: const Color(0xFF6366F1), 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, 8, 16, 32), children: [ if (unread > 0) _UnreadHint(unreadCount: unread), const SizedBox(height: 4), if (today.isNotEmpty) ...[ const _SectionTitle('今天'), const SizedBox(height: 4), ...today.map(_buildDismissible), ], if (earlier.isNotEmpty) ...[ if (today.isNotEmpty) const SizedBox(height: 12), const _SectionTitle('更早'), const SizedBox(height: 4), ...earlier.map(_buildDismissible), ], ], ); } Widget _buildDismissible(InAppNotification item) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 26), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ AppColors.error.withValues(alpha: 0.6), AppColors.error, ], ), borderRadius: BorderRadius.circular(20), ), 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.w600, ), ), ], ), ), onDismissed: (_) => _delete(item), child: _NotificationCard(item: item, onTap: () => _open(item)), ), ); } /// 顶部一行精致提示——只在有未读时显示,不抢眼 class _UnreadHint extends StatelessWidget { final int unreadCount; const _UnreadHint({required this.unreadCount}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ Container( width: 6, height: 6, decoration: const BoxDecoration( color: Color(0xFFEF4444), shape: BoxShape.circle, ), ), const SizedBox(width: 8), Text( '你有 $unreadCount 条未读消息', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ], ), ); } } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(4, 12, 4, 6), child: Text( text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textHint, letterSpacing: 0.3, ), ), ); } class _NotificationCard extends StatelessWidget { final InAppNotification item; final VoidCallback onTap; const _NotificationCard({required this.item, required this.onTap}); @override Widget build(BuildContext context) { final visual = _NotificationVisual.of(item); return Material( color: Colors.white, borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all( color: item.isRead ? const Color(0xFFEEF1F6) : visual.color.withValues(alpha: 0.20), ), boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // 图标 Stack( clipBehavior: Clip.none, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: visual.lightColor, borderRadius: BorderRadius.circular(10), ), child: Icon(visual.icon, size: 18, color: visual.color), ), if (!item.isRead) Positioned( top: -2, right: -2, child: Container( width: 9, height: 9, decoration: BoxDecoration( color: const Color(0xFFEF4444), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 1.5), ), ), ), ], ), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, fontWeight: item.isRead ? FontWeight.w600 : FontWeight.w700, color: AppColors.textPrimary, height: 1.3, ), ), ), const SizedBox(width: 6), Text( _formatTime(item.createdAt), style: const TextStyle( fontSize: 11, color: AppColors.textHint, ), ), ], ), const SizedBox(height: 2), Text( item.message, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12, height: 1.45, color: AppColors.textSecondary, ), ), ], ), ), if (item.actionType != null) ...[ const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, size: 18, color: AppColors.textHint, ), ], ], ), ), ), ); } 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.of(InAppNotification item) { switch (item.actionType) { case 'exercise': return const _NotificationVisual( LucideIcons.activity, Color(0xFF10B981), Color(0xFFD1FAE5), '运动', ); case 'health': if (item.severity == 'critical') { return const _NotificationVisual( LucideIcons.triangleAlert, Color(0xFFEF4444), Color(0xFFFEE2E2), '紧急', ); } return const _NotificationVisual( LucideIcons.heartPulse, Color(0xFFF59E0B), Color(0xFFFEF3C7), '健康', ); case 'report': if (item.severity == 'error') { return const _NotificationVisual( LucideIcons.fileWarning, Color(0xFFEF4444), Color(0xFFFEE2E2), '报告', ); } return const _NotificationVisual( LucideIcons.fileCheck2, Color(0xFF8B5CF6), Color(0xFFEDE9FE), '报告', ); default: return const _NotificationVisual( LucideIcons.pill, Color(0xFF3B82F6), Color(0xFFDBEAFE), '用药', ); } } } 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(24), border: Border.all(color: const Color(0xFFE9EEF5)), boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.04), blurRadius: 14, offset: const Offset(0, 4), ), ], ), child: Column( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], ), borderRadius: BorderRadius.circular(22), ), child: Icon(icon, size: 32, color: const Color(0xFF6366F1)), ), 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: 13, height: 1.5, color: AppColors.textSecondary, ), ), if (buttonText != null) ...[ const SizedBox(height: 22), FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( backgroundColor: const Color(0xFF6366F1), padding: const EdgeInsets.symmetric( horizontal: 28, vertical: 12, ), ), child: Text(buttonText!), ), ], ], ), ), ], ); }