78fd600522
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
271 lines
10 KiB
Dart
271 lines
10 KiB
Dart
// 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<NotificationsScreen> createState() => _NotificationsScreenState();
|
|
}
|
|
|
|
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),
|
|
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 <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: widget.onBack,
|
|
embedded: widget.embedded,
|
|
wrapPageBody: true,
|
|
child: body,
|
|
);
|
|
}
|
|
}
|