diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 493d81c..f171cf6 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -621,6 +621,9 @@ abstract class AppText { String get notifTypeNews; // 新闻 / News String get notifTypeFeature; // 功能 / Feature String get notifTypeImportant; // 重要 / Important + String get notifTypeReward; // 到账 / Credited + String get notifTypeVersion; // 版本 / Update + String get notifTypePromo; // 活动 / Promo // ── 设置分组 ── String get settingsGroupConn; // 连接 / Connection String get settingsGroupAppearance;// 外观 / Appearance diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 0722292..a521855 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -528,6 +528,12 @@ class StringsEn extends AppText { @override String get notifTypeImportant => 'Important'; @override + String get notifTypeReward => 'Credited'; + @override + String get notifTypeVersion => 'Update'; + @override + String get notifTypePromo => 'Promo'; + @override String get settingsGroupConn => 'Connection'; @override String get settingsGroupAppearance => 'Appearance'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index fe0eb13..d20fe0e 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -529,6 +529,12 @@ class StringsEs extends AppText { @override String get notifTypeImportant => 'Importante'; @override + String get notifTypeReward => 'Acreditado'; + @override + String get notifTypeVersion => 'Actualización'; + @override + String get notifTypePromo => 'Promoción'; + @override String get settingsGroupConn => 'Conexión'; @override String get settingsGroupAppearance => 'Apariencia'; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index eb66607..f5762bc 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -529,6 +529,12 @@ class StringsJa extends AppText { @override String get notifTypeImportant => '重要'; @override + String get notifTypeReward => '入金'; + @override + String get notifTypeVersion => 'アップデート'; + @override + String get notifTypePromo => 'キャンペーン'; + @override String get settingsGroupConn => '接続'; @override String get settingsGroupAppearance => '外観'; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 1ab07cc..78da5bc 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -529,6 +529,12 @@ class StringsKo extends AppText { @override String get notifTypeImportant => '중요'; @override + String get notifTypeReward => '적립'; + @override + String get notifTypeVersion => '업데이트'; + @override + String get notifTypePromo => '프로모션'; + @override String get settingsGroupConn => '연결'; @override String get settingsGroupAppearance => '화면'; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index 746be16..f421ebc 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -529,6 +529,12 @@ class StringsRu extends AppText { @override String get notifTypeImportant => 'Важное'; @override + String get notifTypeReward => 'Начислено'; + @override + String get notifTypeVersion => 'Обновление'; + @override + String get notifTypePromo => 'Акция'; + @override String get settingsGroupConn => 'Подключение'; @override String get settingsGroupAppearance => 'Внешний вид'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index eaab549..c1a100a 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -522,6 +522,12 @@ class StringsZh extends AppText { @override String get notifTypeImportant => '重要'; @override + String get notifTypeReward => '到账'; + @override + String get notifTypeVersion => '版本'; + @override + String get notifTypePromo => '活动'; + @override String get settingsGroupConn => '连接'; @override String get settingsGroupAppearance => '外观'; diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 614c961..3f6a775 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -9,6 +9,7 @@ import '../state/account_providers.dart'; import '../state/app_providers.dart'; import '../state/auth_provider.dart'; import '../state/navigation_provider.dart'; +import '../state/notices_provider.dart'; import '../widgets/account_screens.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/notification_bell.dart'; @@ -136,7 +137,7 @@ class AccountPage extends ConsumerWidget { AppTopBar( brand: t.brand, trailing: NotificationBell( - hasUnread: true, + hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0, onTap: () => open(NavView.notifications, NotificationsScreen(t: t)), ), ), diff --git a/client/lib/screens/notifications_page.dart b/client/lib/screens/notifications_page.dart index e87b0df..4de2304 100644 --- a/client/lib/screens/notifications_page.dart +++ b/client/lib/screens/notifications_page.dart @@ -1,62 +1,121 @@ -// notifications_page.dart — 通知(占位屏) +// notifications_page.dart — 通知(真实数据:GET /v1/notices,已读态服务端持久化) // -// TODO(spec-3): 列表来自 GET /v1/notices,已读态本地持久化。当前为 3 条静态示例, -// 仅供 IA 走查——接后端前不接任何真实业务逻辑。 +// 三态: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'; -typedef _Notice = ({ - IconData icon, - PangolinStatus status, - String typeLabel, - String title, - String sub, - bool read, -}); +/// 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 ConsumerWidget { +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 - Widget build(BuildContext context, WidgetRef ref) { - final c = context.pangolin; + ConsumerState createState() => _NotificationsScreenState(); +} - // 静态示例数据(占位):News / Feature / Important 各一条,类型 pill 文案走 t.notifType*。 - final notices = <_Notice>[ - ( - icon: PangolinIcons.bell, - status: PangolinStatus.neutral, - typeLabel: t.notifTypeNews, - title: 'Product update', // TODO(spec-3): placeholder - sub: '2 days ago', - read: false, - ), - ( - icon: PangolinIcons.zap, - status: PangolinStatus.connected, - typeLabel: t.notifTypeFeature, - title: 'New feature released', // TODO(spec-3): placeholder - sub: '5 days ago', - read: false, - ), - ( - icon: PangolinIcons.alertTriangle, - status: PangolinStatus.error, - typeLabel: t.notifTypeImportant, - title: 'Important notice', // TODO(spec-3): placeholder - sub: '1 week ago', - read: true, - ), - ]; +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), @@ -72,77 +131,140 @@ class NotificationsScreen extends ConsumerWidget { ]), ); - Widget row(_Notice n, bool divider) { - return 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(n.icon, size: 18, color: c.accent), - ), - if (!n.read) - Positioned( - top: -2, - right: -2, - child: Container( - 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, - overflow: TextOverflow.ellipsis, - style: PangolinText.sm.copyWith( - color: c.fg1, fontWeight: n.read ? FontWeight.w500 : FontWeight.w700, fontSize: 14.5)), - ), - const SizedBox(width: 8), - StatusPill(label: n.typeLabel, status: n.status), - ]), - const SizedBox(height: 4), - Text(n.sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ]), + 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() => ListView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - children: [ - if (notices.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 < notices.length; i++) row(notices[i], i < notices.length - 1), - ]), + 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: onBack, - embedded: embedded, + onBack: widget.onBack, + embedded: widget.embedded, wrapPageBody: true, - child: content(), + child: body, ); } } diff --git a/client/lib/widgets/content_top_bar.dart b/client/lib/widgets/content_top_bar.dart index 968a784..bfd8404 100644 --- a/client/lib/widgets/content_top_bar.dart +++ b/client/lib/widgets/content_top_bar.dart @@ -9,6 +9,7 @@ import '../pangolin_theme.dart'; import '../state/app_providers.dart'; import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; +import '../state/notices_provider.dart'; import 'notification_bell.dart'; import 'pangolin_icons.dart'; @@ -63,7 +64,10 @@ class ContentTopBar extends ConsumerWidget { ), if (onBell != null) ...[ const SizedBox(width: 12), - NotificationBell(onTap: onBell!, hasUnread: true), + NotificationBell( + onTap: onBell!, + hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0, + ), ], if (showThemeToggle) ...[ const SizedBox(width: 12), diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index 81636c6..f39f1e9 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/desktop_contact.png b/client/test/golden/goldens/desktop_contact.png index c092ee3..18f827e 100644 Binary files a/client/test/golden/goldens/desktop_contact.png and b/client/test/golden/goldens/desktop_contact.png differ diff --git a/client/test/golden/goldens/desktop_devices.png b/client/test/golden/goldens/desktop_devices.png index 0f3ddd4..ea8dc5e 100644 Binary files a/client/test/golden/goldens/desktop_devices.png and b/client/test/golden/goldens/desktop_devices.png differ diff --git a/client/test/golden/goldens/desktop_redeem.png b/client/test/golden/goldens/desktop_redeem.png index fc780d7..7f0f541 100644 Binary files a/client/test/golden/goldens/desktop_redeem.png and b/client/test/golden/goldens/desktop_redeem.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index 213e7aa..606400e 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/desktop_settings.png b/client/test/golden/goldens/desktop_settings.png index 934ae23..b940ebf 100644 Binary files a/client/test/golden/goldens/desktop_settings.png and b/client/test/golden/goldens/desktop_settings.png differ diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index 87745f0..ab1e03a 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png index 255e347..99b9f71 100644 Binary files a/client/test/golden/goldens/tablet_account_dark_zh.png and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png index d623e45..ff7cc32 100644 Binary files a/client/test/golden/goldens/tablet_account_light_en.png and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png index 3072180..2a4a308 100644 Binary files a/client/test/golden/goldens/tablet_account_light_zh.png and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_dark_zh.png b/client/test/golden/goldens/tablet_connect_dark_zh.png index dba1d1c..906cbdb 100644 Binary files a/client/test/golden/goldens/tablet_connect_dark_zh.png and b/client/test/golden/goldens/tablet_connect_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_en.png b/client/test/golden/goldens/tablet_connect_light_en.png index fc34b41..ea41a50 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_en.png and b/client/test/golden/goldens/tablet_connect_light_en.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_zh.png b/client/test/golden/goldens/tablet_connect_light_zh.png index 425f27f..5b03255 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_zh.png and b/client/test/golden/goldens/tablet_connect_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png index b09fab3..ac79cf0 100644 Binary files a/client/test/golden/goldens/tablet_servers_dark_zh.png and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png index b8366a5..331f08b 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_en.png and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png index 4368000..b773618 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_zh.png and b/client/test/golden/goldens/tablet_servers_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png index 0a7c1a1..4506950 100644 Binary files a/client/test/golden/goldens/tablet_stats_dark_zh.png and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png index e1a9c43..cb31c2f 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_en.png and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png index 521e72d..be6e447 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_zh.png and b/client/test/golden/goldens/tablet_stats_light_zh.png differ diff --git a/client/test/widget/notifications_page_test.dart b/client/test/widget/notifications_page_test.dart new file mode 100644 index 0000000..49648b7 --- /dev/null +++ b/client/test/widget/notifications_page_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_en.dart'; +import 'package:pangolin_vpn/screens/notifications_page.dart'; +import 'package:pangolin_vpn/services/notices_api.dart'; +import 'package:pangolin_vpn/state/notices_provider.dart'; + +import '../helpers/harness.dart'; + +void main() { + testWidgets('通知页真实数据渲染:type pill + 未读红点 + version 去更新按钮 + 进页清读', + (tester) async { + final now = DateTime.now(); + final data = NoticesData(items: [ + NoticeItem( + id: 1, + type: 'reward', + titleZh: '测试到账', + titleEn: 'Reward credited', + bodyZh: '你获得了 7 天会员', + bodyEn: 'You earned 7 days membership', + link: '', + publishedAt: now, + unread: true, + ), + NoticeItem( + id: 2, + type: 'version', + titleZh: '新版本发布', + titleEn: 'New version available', + bodyZh: '', + bodyEn: '', + link: '', + publishedAt: now, + unread: false, + ), + NoticeItem( + id: 3, + type: 'news', + titleZh: '产品新闻', + titleEn: 'Product news', + bodyZh: '', + bodyEn: '', + link: '', + publishedAt: now, + unread: false, + ), + ], unreadCount: 1); + + final fake = _FakeNotifier(data); + + await tester.binding.setSurfaceSize(const Size(420, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(wrapThemed( + NotificationsScreen(t: StringsEn()), + overrides: [noticesProvider.overrideWith(() => fake)], + )); + await tester.pumpAndSettle(); + + // reward 行 type pill 文案(StringsEn 的 'Credited')在。 + expect(find.text('Credited'), findsOneWidget); + // reward 行未读红点在(fake.markAllRead 不改写数据,仅记录调用)。 + expect(find.byKey(const ValueKey('notif-unread-1')), findsOneWidget); + // version 行「去更新」按钮在(t.lang.updateNow == 'Update now')。 + expect(find.text('Update now'), findsOneWidget); + // 首帧后调用了 markAllRead()。 + expect(fake.markAllReadCalled, isTrue); + }); +} + +/// 照 invite_page_test.dart 的 `_FakeNotifier extends InviteNotifier` 模式:override +/// build() 直接返回固定数据;markAllRead 只记录调用、不改写数据(便于断言未读态渲染 +/// 与「已调用清读」两件事互不干扰)。 +class _FakeNotifier extends NoticesNotifier { + _FakeNotifier(this._v); + final NoticesData _v; + bool markAllReadCalled = false; + + @override + Future build() async => _v; + + @override + Future markAllRead() async { + markAllReadCalled = true; + } +}