feat(client/notices): 通知页真实化+铃铛红点接 provider+进页清读+三类型标签六语
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => '外観';
|
||||
|
||||
@@ -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 => '화면';
|
||||
|
||||
@@ -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 => 'Внешний вид';
|
||||
|
||||
@@ -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 => '外观';
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<NotificationsScreen> 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<NotificationsScreen> with WidgetsBindingObserver {
|
||||
final Set<int> _expanded = {};
|
||||
ProviderSubscription<AsyncValue<NoticesData?>>? _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<void> _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 <NoticeItem>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
@@ -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<NoticesData?> build() async => _v;
|
||||
|
||||
@override
|
||||
Future<void> markAllRead() async {
|
||||
markAllReadCalled = true;
|
||||
}
|
||||
}
|
||||