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 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
+6
View File
@@ -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';
+6
View File
@@ -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';
+6
View File
@@ -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 => '外観';
+6
View File
@@ -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 => '화면';
+6
View File
@@ -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 => 'Внешний вид';
+6
View File
@@ -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 => '外观';
+2 -1
View File
@@ -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)),
),
),
+225 -103
View File
@@ -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,
);
}
}
+5 -1
View File
@@ -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),
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;
}
}