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:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||||
|
import 'package:pangolin_vpn/services/api_client.dart';
|
||||||
|
import 'package:pangolin_vpn/services/notices_api.dart';
|
||||||
|
|
||||||
|
ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m);
|
||||||
|
|
||||||
|
const _utf8Json = {'content-type': 'application/json; charset=utf-8'};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('fetch 解析公告列表 + unread_count(双语字段)', () async {
|
||||||
|
final api = NoticesApi(_c(MockClient((req) async {
|
||||||
|
expect(req.url.path, '/v1/notices');
|
||||||
|
return http.Response(
|
||||||
|
'{"notices":['
|
||||||
|
'{"id":1,"type":"maintenance","title_zh":"维护通知","title_en":"Maintenance",'
|
||||||
|
'"body_zh":"今晚维护","body_en":"Tonight maintenance","link":"https://x/1",'
|
||||||
|
'"published_at":"2026-07-10T08:00:00Z","unread":true},'
|
||||||
|
'{"id":2,"type":"promo","title_zh":"活动","title_en":"Promo",'
|
||||||
|
'"published_at":"2026-07-09T08:00:00Z","unread":false}'
|
||||||
|
'],"unread_count":1}',
|
||||||
|
200,
|
||||||
|
headers: _utf8Json);
|
||||||
|
})));
|
||||||
|
final data = await api.fetch();
|
||||||
|
expect(data.items.length, 2);
|
||||||
|
expect(data.unreadCount, 1);
|
||||||
|
|
||||||
|
final first = data.items[0];
|
||||||
|
expect(first.id, 1);
|
||||||
|
expect(first.type, 'maintenance');
|
||||||
|
expect(first.titleZh, '维护通知');
|
||||||
|
expect(first.titleEn, 'Maintenance');
|
||||||
|
expect(first.bodyZh, '今晚维护');
|
||||||
|
expect(first.bodyEn, 'Tonight maintenance');
|
||||||
|
expect(first.link, 'https://x/1');
|
||||||
|
expect(first.publishedAt, DateTime.parse('2026-07-10T08:00:00Z'));
|
||||||
|
expect(first.unread, true);
|
||||||
|
expect(first.title(AppLang.zh), '维护通知');
|
||||||
|
expect(first.title(AppLang.en), 'Maintenance');
|
||||||
|
|
||||||
|
final second = data.items[1];
|
||||||
|
expect(second.id, 2);
|
||||||
|
expect(second.bodyZh, '');
|
||||||
|
expect(second.bodyEn, '');
|
||||||
|
expect(second.link, '');
|
||||||
|
expect(second.unread, false);
|
||||||
|
// 非 zh 语言取 en 兜底
|
||||||
|
expect(second.title(AppLang.ja), second.titleEn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch 缺字段安全默认(不 crash)', () async {
|
||||||
|
final api = NoticesApi(_c(MockClient((req) async {
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
})));
|
||||||
|
final data = await api.fetch();
|
||||||
|
expect(data.items, isEmpty);
|
||||||
|
expect(data.unreadCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markRead 打 POST /v1/notices/read', () async {
|
||||||
|
var called = false;
|
||||||
|
final api = NoticesApi(_c(MockClient((req) async {
|
||||||
|
called = true;
|
||||||
|
expect(req.method, 'POST');
|
||||||
|
expect(req.url.path, '/v1/notices/read');
|
||||||
|
return http.Response('{"ok":true}', 200);
|
||||||
|
})));
|
||||||
|
await api.markRead();
|
||||||
|
expect(called, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user