Files
pangolin/client/lib/screens/notifications_page.dart
T

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