// notifications_page.dart — 通知(真实数据:GET /v1/notices,已读态服务端持久化) // // 三态:loading(居中进度圈)/ data 为空(t.notifEmpty 空态)/ error(t.lang.loadFailedRetry)。 // 类型 pill 走 news/feature/important/reward/version/promo 六值映射(未知 type 原样显示 // type 字符串)。点击行展开/收起正文(body 为空则不可展开)。version 类型行尾「去更新」 // 接现有 updateProvider.forceCheck() 流程(与 settings_page.dart 的手动检查同一套逻辑)。 // 首帧后(有未读时)调一次 markAllRead();回前台(resumed)刷新一次列表。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; import '../services/notices_api.dart'; import '../state/notices_provider.dart'; import '../state/update_provider.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; import '../widgets/status_pill.dart'; import '../widgets/sub_scaffold.dart'; /// type → (图标, 状态色, 文案) 映射;六个后端合法值 news/feature/important/reward/ /// version/promo。未知 type 原样显示(label=type 字面量,兜底 bell/neutral)。 ({IconData icon, PangolinStatus status, String label}) _typeMeta(String type, AppText t) { switch (type) { case 'news': return (icon: PangolinIcons.bell, status: PangolinStatus.neutral, label: t.notifTypeNews); case 'feature': return (icon: PangolinIcons.zap, status: PangolinStatus.connected, label: t.notifTypeFeature); case 'important': return (icon: PangolinIcons.alertTriangle, status: PangolinStatus.error, label: t.notifTypeImportant); case 'reward': return (icon: PangolinIcons.gift, status: PangolinStatus.connected, label: t.notifTypeReward); case 'version': return (icon: PangolinIcons.refreshCw, status: PangolinStatus.neutral, label: t.notifTypeVersion); case 'promo': return (icon: PangolinIcons.crown, status: PangolinStatus.pro, label: t.notifTypePromo); default: return (icon: PangolinIcons.bell, status: PangolinStatus.neutral, label: type); } } class NotificationsScreen extends ConsumerStatefulWidget { const NotificationsScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; @override ConsumerState createState() => _NotificationsScreenState(); } class _NotificationsScreenState extends ConsumerState with WidgetsBindingObserver { final Set _expanded = {}; ProviderSubscription>? _noticesSub; bool _marked = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); void tryMarkAllRead() { if (_marked) return; final data = ref.read(noticesProvider).valueOrNull; if (data != null && data.unreadCount > 0) { _marked = true; ref.read(noticesProvider.notifier).markAllRead(); } } // 首帧后标全部已读(仅当当前已有数据且存在未读,避免每次进页空转一次网络请求)。 // provider 首拉有延迟(build() 惰性等 2s),首帧时数据可能仍在 loading;因此额外 // listenManual 监听后续到达的数据补触发一次,保证「进页即清读」不受首拉时序影响。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; tryMarkAllRead(); }); _noticesSub = ref.listenManual(noticesProvider, (prev, next) => tryMarkAllRead()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _noticesSub?.close(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 回前台刷新(全局无 lifecycle 监听点,落在页内;详见 Task 10 报告)。 if (state == AppLifecycleState.resumed && mounted) { ref.read(noticesProvider.notifier).refresh(); } } /// version 类型通知行「去更新」:接 settings_page.dart 同一套 updateProvider.forceCheck() /// 流程(最小接法,不新开更新入口)。 Future _goUpdate() async { final t = widget.t; final info = await ref.read(updateProvider.notifier).forceCheck(); if (!mounted) return; if (info == null) { showPangolinToast(context, t.updateCheckFailed); return; } if (!info.hasUpdate) { showPangolinToast(context, t.updateUpToDate); return; } ref.read(dismissedUpdateVersionProvider.notifier).state = null; showPangolinToast(context, t.updateAvailableTitle(info.latestVersion)); } @override Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; final async = ref.watch(noticesProvider); Widget empty() => Padding( padding: const EdgeInsets.only(top: 60), child: Column(children: [ Container( width: 56, height: 56, decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle), child: Icon(PangolinIcons.bell, size: 26, color: c.fg3), ), const SizedBox(height: 14), Text(t.notifEmpty, style: PangolinText.sm.copyWith(color: c.fg3)), ]), ); Widget loading() => const Padding( padding: EdgeInsets.only(top: 80), child: Center(child: CircularProgressIndicator()), ); Widget errorView() => Padding( padding: const EdgeInsets.only(top: 60), child: Center( child: Text(t.lang.loadFailedRetry, style: PangolinText.sm.copyWith(color: c.fg3)), ), ); Widget row(NoticeItem n, bool divider) { final meta = _typeMeta(n.type, t); final body = t.lang == AppLang.zh ? n.bodyZh : n.bodyEn; final hasBody = body.trim().isNotEmpty; final expanded = _expanded.contains(n.id); final subLabel = n.publishedAt != null ? t.lang.relativeTime(DateTime.now().difference(n.publishedAt!)) : null; return InkWell( onTap: hasBody ? () => setState(() { if (expanded) { _expanded.remove(n.id); } else { _expanded.add(n.id); } }) : null, child: Container( decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack(clipBehavior: Clip.none, children: [ Container( width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), child: Icon(meta.icon, size: 18, color: c.accent), ), if (n.unread) Positioned( top: -2, right: -2, child: Container( key: ValueKey('notif-unread-${n.id}'), width: 8, height: 8, decoration: BoxDecoration(color: c.danger, shape: BoxShape.circle), ), ), ]), const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ Expanded( child: Text(n.title(t.lang), overflow: TextOverflow.ellipsis, style: PangolinText.sm.copyWith( color: c.fg1, fontWeight: n.unread ? FontWeight.w700 : FontWeight.w500, fontSize: 14.5)), ), const SizedBox(width: 8), StatusPill(label: meta.label, status: meta.status), ]), if (subLabel != null) ...[ const SizedBox(height: 4), Text(subLabel, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), ], if (expanded && hasBody) ...[ const SizedBox(height: 8), Text(body, style: PangolinText.sm.copyWith(color: c.fg2, fontSize: 13.5)), ], if (n.type == 'version') ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: OutlinedButton( onPressed: _goUpdate, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, side: BorderSide(color: c.border), ), child: Text(t.lang.updateNow, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600)), ), ), ], ]), ), ]), ), ); } Widget content(NoticesData? data) { final items = data?.items ?? const []; return ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [ if (items.isEmpty) empty() else Container( decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm, ), clipBehavior: Clip.antiAlias, child: Column(children: [ for (var i = 0; i < items.length; i++) row(items[i], i < items.length - 1), ]), ), ], ); } final body = async.when( loading: () => loading(), error: (err, st) => errorView(), data: (data) => content(data), ); return SubScaffold( title: t.notifTitle, onBack: widget.onBack, embedded: widget.embedded, wrapPageBody: true, child: body, ); } }