feat(client): notices api + provider(未读计数/markAllRead/延迟首拉)

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 13:48:12 +08:00
parent 0325603470
commit 6a782a07fd
3 changed files with 222 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
// notices_api.dart — 系统通知代理端点封装(JWT 经 ApiClient 自动注入)。
import '../l10n/app_text.dart';
import 'api_client.dart';
class NoticeItem {
const NoticeItem({
required this.id,
required this.type,
required this.titleZh,
required this.titleEn,
required this.bodyZh,
required this.bodyEn,
required this.link,
required this.publishedAt,
required this.unread,
});
final int id;
final String type, titleZh, titleEn, bodyZh, bodyEn, link;
final DateTime? publishedAt;
final bool unread;
/// 单显文案:zh 取中文,非 zh(en/ja/ko/ru/es)一律取英文兜底。
String title(AppLang lang) => lang == AppLang.zh ? titleZh : titleEn;
factory NoticeItem.fromJson(Map<String, dynamic> j) {
DateTime? published;
final raw = j['published_at'] as String?;
if (raw != null && raw.isNotEmpty) {
try {
published = DateTime.parse(raw);
} catch (_) {
published = null;
}
}
return NoticeItem(
id: (j['id'] as num?)?.toInt() ?? 0,
type: j['type'] as String? ?? '',
titleZh: j['title_zh'] as String? ?? '',
titleEn: j['title_en'] as String? ?? '',
bodyZh: j['body_zh'] as String? ?? '',
bodyEn: j['body_en'] as String? ?? '',
link: j['link'] as String? ?? '',
publishedAt: published,
unread: j['unread'] as bool? ?? false,
);
}
NoticeItem copyWith({bool? unread}) => NoticeItem(
id: id,
type: type,
titleZh: titleZh,
titleEn: titleEn,
bodyZh: bodyZh,
bodyEn: bodyEn,
link: link,
publishedAt: publishedAt,
unread: unread ?? this.unread,
);
}
class NoticesData {
const NoticesData({required this.items, required this.unreadCount});
final List<NoticeItem> items;
final int unreadCount;
factory NoticesData.fromJson(Map<String, dynamic> j) {
final rawItems = j['notices'] as List<dynamic>? ?? const [];
return NoticesData(
items: rawItems
.whereType<Map<String, dynamic>>()
.map(NoticeItem.fromJson)
.toList(),
unreadCount: (j['unread_count'] as num?)?.toInt() ?? 0,
);
}
NoticesData copyWith({List<NoticeItem>? items, int? unreadCount}) =>
NoticesData(items: items ?? this.items, unreadCount: unreadCount ?? this.unreadCount);
}
class NoticesApi {
NoticesApi(this._c);
final ApiClient _c;
Future<NoticesData> fetch() async => NoticesData.fromJson(await _c.getJson('/v1/notices'));
Future<void> markRead() async {
await _c.postJson('/v1/notices/read');
}
}
+56
View File
@@ -0,0 +1,56 @@
// notices_provider.dart — 系统通知状态装配:复用 apiClientProvider,未登录不打网络。
//
// 对照 invite_provider.dart(装配模式:未登录 build 返回 null、不打网络)与
// update_provider.dart(延迟首拉的可取消 Timer 模式)。回前台重拉由 Task 10 的
// 消费方(WidgetsBindingObserver)调用 refresh(),本 provider 只暴露 refresh()。
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/notices_api.dart';
import 'account_providers.dart';
import 'auth_provider.dart';
/// 首次拉取前的延迟(避开登录/首屏竞争)。
const _kInitialDelay = Duration(seconds: 2);
final noticesApiProvider = Provider<NoticesApi>((ref) => NoticesApi(ref.watch(apiClientProvider)));
class NoticesNotifier extends AsyncNotifier<NoticesData?> {
Timer? _initialTimer;
@override
Future<NoticesData?> build() async {
ref.onDispose(() => _initialTimer?.cancel());
// 未登录返回 null(不打网络)。
final token = ref.watch(authProvider).accessToken;
if (token == null || token.isEmpty) return null;
// 延迟首拉:用可取消的 Timer(而非 Future.delayed,其内部 timer 无法取消,
// provider 在延迟期间被 dispose 时会悬挂)。
final ready = Completer<void>();
_initialTimer = Timer(_kInitialDelay, ready.complete);
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
return ref.read(noticesApiProvider).fetch();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(noticesApiProvider).fetch());
}
/// 标记全部已读:调服务端成功后,本地把 unreadCount 置 0、items 的 unread 置 false。
Future<void> markAllRead() async {
final current = state.value;
if (current == null) return;
await ref.read(noticesApiProvider).markRead();
state = AsyncValue.data(current.copyWith(
items: current.items.map((n) => n.copyWith(unread: false)).toList(),
unreadCount: 0,
));
}
}
final noticesProvider = AsyncNotifierProvider<NoticesNotifier, NoticesData?>(NoticesNotifier.new);