feat(client/notices): 通知页真实化+铃铛红点接 provider+进页清读+三类型标签六语

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 14:05:47 +08:00
parent 84b162e834
commit 78fd600522
30 changed files with 358 additions and 105 deletions
+3
View File
@@ -621,6 +621,9 @@ abstract class AppText {
String get notifTypeNews; // 新闻 / News String get notifTypeNews; // 新闻 / News
String get notifTypeFeature; // 功能 / Feature String get notifTypeFeature; // 功能 / Feature
String get notifTypeImportant; // 重要 / Important String get notifTypeImportant; // 重要 / Important
String get notifTypeReward; // 到账 / Credited
String get notifTypeVersion; // 版本 / Update
String get notifTypePromo; // 活动 / Promo
// ── 设置分组 ── // ── 设置分组 ──
String get settingsGroupConn; // 连接 / Connection String get settingsGroupConn; // 连接 / Connection
String get settingsGroupAppearance;// 外观 / Appearance String get settingsGroupAppearance;// 外观 / Appearance
+6
View File
@@ -528,6 +528,12 @@ class StringsEn extends AppText {
@override @override
String get notifTypeImportant => 'Important'; String get notifTypeImportant => 'Important';
@override @override
String get notifTypeReward => 'Credited';
@override
String get notifTypeVersion => 'Update';
@override
String get notifTypePromo => 'Promo';
@override
String get settingsGroupConn => 'Connection'; String get settingsGroupConn => 'Connection';
@override @override
String get settingsGroupAppearance => 'Appearance'; String get settingsGroupAppearance => 'Appearance';
+6
View File
@@ -529,6 +529,12 @@ class StringsEs extends AppText {
@override @override
String get notifTypeImportant => 'Importante'; String get notifTypeImportant => 'Importante';
@override @override
String get notifTypeReward => 'Acreditado';
@override
String get notifTypeVersion => 'Actualización';
@override
String get notifTypePromo => 'Promoción';
@override
String get settingsGroupConn => 'Conexión'; String get settingsGroupConn => 'Conexión';
@override @override
String get settingsGroupAppearance => 'Apariencia'; String get settingsGroupAppearance => 'Apariencia';
+6
View File
@@ -529,6 +529,12 @@ class StringsJa extends AppText {
@override @override
String get notifTypeImportant => '重要'; String get notifTypeImportant => '重要';
@override @override
String get notifTypeReward => '入金';
@override
String get notifTypeVersion => 'アップデート';
@override
String get notifTypePromo => 'キャンペーン';
@override
String get settingsGroupConn => '接続'; String get settingsGroupConn => '接続';
@override @override
String get settingsGroupAppearance => '外観'; String get settingsGroupAppearance => '外観';
+6
View File
@@ -529,6 +529,12 @@ class StringsKo extends AppText {
@override @override
String get notifTypeImportant => '중요'; String get notifTypeImportant => '중요';
@override @override
String get notifTypeReward => '적립';
@override
String get notifTypeVersion => '업데이트';
@override
String get notifTypePromo => '프로모션';
@override
String get settingsGroupConn => '연결'; String get settingsGroupConn => '연결';
@override @override
String get settingsGroupAppearance => '화면'; String get settingsGroupAppearance => '화면';
+6
View File
@@ -529,6 +529,12 @@ class StringsRu extends AppText {
@override @override
String get notifTypeImportant => 'Важное'; String get notifTypeImportant => 'Важное';
@override @override
String get notifTypeReward => 'Начислено';
@override
String get notifTypeVersion => 'Обновление';
@override
String get notifTypePromo => 'Акция';
@override
String get settingsGroupConn => 'Подключение'; String get settingsGroupConn => 'Подключение';
@override @override
String get settingsGroupAppearance => 'Внешний вид'; String get settingsGroupAppearance => 'Внешний вид';
+6
View File
@@ -522,6 +522,12 @@ class StringsZh extends AppText {
@override @override
String get notifTypeImportant => '重要'; String get notifTypeImportant => '重要';
@override @override
String get notifTypeReward => '到账';
@override
String get notifTypeVersion => '版本';
@override
String get notifTypePromo => '活动';
@override
String get settingsGroupConn => '连接'; String get settingsGroupConn => '连接';
@override @override
String get settingsGroupAppearance => '外观'; String get settingsGroupAppearance => '外观';
+2 -1
View File
@@ -9,6 +9,7 @@ import '../state/account_providers.dart';
import '../state/app_providers.dart'; import '../state/app_providers.dart';
import '../state/auth_provider.dart'; import '../state/auth_provider.dart';
import '../state/navigation_provider.dart'; import '../state/navigation_provider.dart';
import '../state/notices_provider.dart';
import '../widgets/account_screens.dart'; import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart'; import '../widgets/app_top_bar.dart';
import '../widgets/notification_bell.dart'; import '../widgets/notification_bell.dart';
@@ -136,7 +137,7 @@ class AccountPage extends ConsumerWidget {
AppTopBar( AppTopBar(
brand: t.brand, brand: t.brand,
trailing: NotificationBell( trailing: NotificationBell(
hasUnread: true, hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0,
onTap: () => open(NavView.notifications, NotificationsScreen(t: t)), onTap: () => open(NavView.notifications, NotificationsScreen(t: t)),
), ),
), ),
+225 -103
View File
@@ -1,62 +1,121 @@
// notifications_page.dart — 通知(占位屏) // notifications_page.dart — 通知(真实数据:GET /v1/notices,已读态服务端持久化)
// //
// TODO(spec-3): 列表来自 GET /v1/notices,已读态本地持久化。当前为 3 条静态示例, // 三态:loading(居中进度圈)/ data 为空(t.notifEmpty 空态)/ error(t.lang.loadFailedRetry)。
// 仅供 IA 走查——接后端前不接任何真实业务逻辑。 // 类型 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart'; import '../l10n/app_text.dart';
import '../pangolin_theme.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_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/status_pill.dart'; import '../widgets/status_pill.dart';
import '../widgets/sub_scaffold.dart'; import '../widgets/sub_scaffold.dart';
typedef _Notice = ({ /// type → (图标, 状态色, 文案) 映射;六个后端合法值 news/feature/important/reward/
IconData icon, /// version/promo。未知 type 原样显示(label=type 字面量,兜底 bell/neutral)。
PangolinStatus status, ({IconData icon, PangolinStatus status, String label}) _typeMeta(String type, AppText t) {
String typeLabel, switch (type) {
String title, case 'news':
String sub, return (icon: PangolinIcons.bell, status: PangolinStatus.neutral, label: t.notifTypeNews);
bool read, 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}); const NotificationsScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t; final AppText t;
final VoidCallback? onBack; final VoidCallback? onBack;
final bool embedded; final bool embedded;
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<NotificationsScreen> createState() => _NotificationsScreenState();
final c = context.pangolin; }
// 静态示例数据(占位):News / Feature / Important 各一条,类型 pill 文案走 t.notifType*。 class _NotificationsScreenState extends ConsumerState<NotificationsScreen> with WidgetsBindingObserver {
final notices = <_Notice>[ final Set<int> _expanded = {};
( ProviderSubscription<AsyncValue<NoticesData?>>? _noticesSub;
icon: PangolinIcons.bell, bool _marked = false;
status: PangolinStatus.neutral,
typeLabel: t.notifTypeNews, @override
title: 'Product update', // TODO(spec-3): placeholder void initState() {
sub: '2 days ago', super.initState();
read: false, WidgetsBinding.instance.addObserver(this);
),
( void tryMarkAllRead() {
icon: PangolinIcons.zap, if (_marked) return;
status: PangolinStatus.connected, final data = ref.read(noticesProvider).valueOrNull;
typeLabel: t.notifTypeFeature, if (data != null && data.unreadCount > 0) {
title: 'New feature released', // TODO(spec-3): placeholder _marked = true;
sub: '5 days ago', ref.read(noticesProvider.notifier).markAllRead();
read: false, }
), }
(
icon: PangolinIcons.alertTriangle, // 首帧后标全部已读(仅当当前已有数据且存在未读,避免每次进页空转一次网络请求)。
status: PangolinStatus.error, // provider 首拉有延迟(build() 惰性等 2s),首帧时数据可能仍在 loading;因此额外
typeLabel: t.notifTypeImportant, // listenManual 监听后续到达的数据补触发一次,保证「进页即清读」不受首拉时序影响。
title: 'Important notice', // TODO(spec-3): placeholder WidgetsBinding.instance.addPostFrameCallback((_) {
sub: '1 week ago', if (!mounted) return;
read: true, 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( Widget empty() => Padding(
padding: const EdgeInsets.only(top: 60), padding: const EdgeInsets.only(top: 60),
@@ -72,77 +131,140 @@ class NotificationsScreen extends ConsumerWidget {
]), ]),
); );
Widget row(_Notice n, bool divider) { Widget loading() => const Padding(
return Container( padding: EdgeInsets.only(top: 80),
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), child: Center(child: CircularProgressIndicator()),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), );
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Stack(clipBehavior: Clip.none, children: [ Widget errorView() => Padding(
Container( padding: const EdgeInsets.only(top: 60),
width: 38, child: Center(
height: 38, child: Text(t.lang.loadFailedRetry, style: PangolinText.sm.copyWith(color: c.fg3)),
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 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( Widget content(NoticesData? data) {
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), final items = data?.items ?? const <NoticeItem>[];
children: [ return ListView(
if (notices.isEmpty) padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
empty() children: [
else if (items.isEmpty)
Container( empty()
decoration: BoxDecoration( else
color: c.surface, Container(
borderRadius: BorderRadius.circular(PangolinRadius.lg), decoration: BoxDecoration(
border: Border.all(color: c.border), color: c.surface,
boxShadow: PangolinShadow.sm, borderRadius: BorderRadius.circular(PangolinRadius.lg),
), border: Border.all(color: c.border),
clipBehavior: Clip.antiAlias, boxShadow: PangolinShadow.sm,
child: Column(children: [
for (var i = 0; i < notices.length; i++) row(notices[i], i < notices.length - 1),
]),
), ),
], 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( return SubScaffold(
title: t.notifTitle, title: t.notifTitle,
onBack: onBack, onBack: widget.onBack,
embedded: embedded, embedded: widget.embedded,
wrapPageBody: true, wrapPageBody: true,
child: content(), child: body,
); );
} }
} }
+5 -1
View File
@@ -9,6 +9,7 @@ import '../pangolin_theme.dart';
import '../state/app_providers.dart'; import '../state/app_providers.dart';
import '../state/connection_provider.dart'; import '../state/connection_provider.dart';
import '../state/nodes_provider.dart'; import '../state/nodes_provider.dart';
import '../state/notices_provider.dart';
import 'notification_bell.dart'; import 'notification_bell.dart';
import 'pangolin_icons.dart'; import 'pangolin_icons.dart';
@@ -63,7 +64,10 @@ class ContentTopBar extends ConsumerWidget {
), ),
if (onBell != null) ...[ if (onBell != null) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
NotificationBell(onTap: onBell!, hasUnread: true), NotificationBell(
onTap: onBell!,
hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0,
),
], ],
if (showThemeToggle) ...[ if (showThemeToggle) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

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;
}
}