feat(client): P1 真实数据接入地基(#6 6A)
- api_config.dart:API 基址单源(消除各处重复 _kApiUrl) - api_client.dart:统一鉴权 HTTP 客户端,401→AuthNotifier.refresh→重试一次, 错误统一 AuthApiException;+ 单测覆盖 401 刷新重试 - 模型 me/device/plan/usage_point(对齐后端 snake_case 契约) - account_api.dart:/v1/me、/v1/plans、/v1/me/devices(列表+删)、/v1/usage、 /v1/redeem、/v1/ads/unlock 封装 - auth_provider:注入 AuthApi + refresh();account_providers 装配 ApiClient/AccountApi - 顺带修复 onboarding 提交遗留的测试 stub(_NullTokenStore 缺 isOnboarded/markOnboarded) flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// device.dart — 已登录设备(GET /v1/me/devices)。
|
||||
class Device {
|
||||
const Device({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.platform,
|
||||
this.lastSeen,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String name;
|
||||
|
||||
/// 'ios' | 'android' | 'windows' | 'macos'。
|
||||
final String platform;
|
||||
|
||||
/// 最近活跃(UTC);从未上线为 null。
|
||||
final DateTime? lastSeen;
|
||||
|
||||
factory Device.fromJson(Map<String, dynamic> m) {
|
||||
final ls = m['last_seen'] as String?;
|
||||
return Device(
|
||||
uuid: m['uuid'] as String? ?? '',
|
||||
name: m['name'] as String? ?? '',
|
||||
platform: m['platform'] as String? ?? '',
|
||||
lastSeen: (ls != null && ls.isNotEmpty) ? DateTime.tryParse(ls) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// me.dart — 当前账户聚合视图(GET /v1/me)。
|
||||
//
|
||||
// 一发覆盖账号/套餐/配额/今日+周流量,对齐后端契约
|
||||
// (server/internal/httpapi/account.go + web/usercenter http.ts)。
|
||||
|
||||
/// 账户档案。字段对齐后端 snake_case JSON。
|
||||
class Me {
|
||||
const Me({
|
||||
required this.email,
|
||||
required this.plan,
|
||||
this.expiresAt,
|
||||
this.devicesUsed = 0,
|
||||
this.devicesMax = 1,
|
||||
this.quotaTodayMin,
|
||||
this.dataTodayGb = 0,
|
||||
this.weeklyGb = const [],
|
||||
this.totpEnabled = false,
|
||||
});
|
||||
|
||||
final String email;
|
||||
|
||||
/// 'free' | 'pro' | 'team'。
|
||||
final String plan;
|
||||
|
||||
/// 订阅到期(UTC);免费版/无订阅为 null。
|
||||
final DateTime? expiresAt;
|
||||
|
||||
final int devicesUsed;
|
||||
final int devicesMax;
|
||||
|
||||
/// 今日额度上限(分钟);null = 不限(pro/team)。
|
||||
final int? quotaTodayMin;
|
||||
|
||||
/// 今日已用流量(GB)。
|
||||
final double dataTodayGb;
|
||||
|
||||
/// 近 7 天每日流量(GB),旧→新。
|
||||
final List<double> weeklyGb;
|
||||
|
||||
final bool totpEnabled;
|
||||
|
||||
bool get isFree => plan == 'free';
|
||||
|
||||
factory Me.fromJson(Map<String, dynamic> m) {
|
||||
final exp = (m['expires_at'] ?? m['expire_at']) as String?;
|
||||
return Me(
|
||||
email: m['email'] as String? ?? '',
|
||||
plan: m['plan'] as String? ?? 'free',
|
||||
expiresAt: (exp != null && exp.isNotEmpty) ? DateTime.tryParse(exp) : null,
|
||||
devicesUsed: (m['devices_used'] as num?)?.toInt() ?? 0,
|
||||
devicesMax: (m['devices_max'] as num?)?.toInt() ?? 1,
|
||||
quotaTodayMin: (m['quota_today_min'] as num?)?.toInt(),
|
||||
dataTodayGb: (m['data_today_gb'] as num?)?.toDouble() ?? 0,
|
||||
weeklyGb: ((m['weekly_gb'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
totpEnabled: m['totp_enabled'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// plan.dart — 套餐定义(GET /v1/plans)。
|
||||
//
|
||||
// 价格字段(price_cents/currency/period)由后端 P2 新增;旧响应缺失时降级为 0。
|
||||
import '../l10n/app_text.dart';
|
||||
|
||||
class Plan {
|
||||
const Plan({
|
||||
required this.code,
|
||||
required this.nameZh,
|
||||
required this.nameEn,
|
||||
this.dailyMinutes,
|
||||
this.adGate = false,
|
||||
this.priceCents = 0,
|
||||
this.currency = 'CNY',
|
||||
this.period = 'month',
|
||||
});
|
||||
|
||||
/// 'free' | 'pro' | 'team'。
|
||||
final String code;
|
||||
final String nameZh;
|
||||
final String nameEn;
|
||||
|
||||
/// 每日额度(分钟);null = 不限。
|
||||
final int? dailyMinutes;
|
||||
final bool adGate;
|
||||
|
||||
/// 价格(分)。0 = 免费。
|
||||
final int priceCents;
|
||||
final String currency;
|
||||
|
||||
/// 计费周期:'month' | 'year'。
|
||||
final String period;
|
||||
|
||||
String localizedName(AppLang lang) => lang == AppLang.zh ? nameZh : nameEn;
|
||||
|
||||
/// 价格展示:¥0 / ¥25 …(整数分转元,去尾零)。
|
||||
String priceLabel() {
|
||||
final symbol = currency == 'CNY' ? '¥' : (currency == 'USD' ? '\$' : '');
|
||||
final yuan = priceCents / 100.0;
|
||||
final s = yuan == yuan.roundToDouble() ? yuan.toStringAsFixed(0) : yuan.toStringAsFixed(2);
|
||||
return '$symbol$s';
|
||||
}
|
||||
|
||||
factory Plan.fromJson(Map<String, dynamic> m) => Plan(
|
||||
code: m['code'] as String? ?? '',
|
||||
nameZh: m['name_zh'] as String? ?? '',
|
||||
nameEn: m['name_en'] as String? ?? '',
|
||||
dailyMinutes: (m['daily_minutes'] as num?)?.toInt(),
|
||||
adGate: m['ad_gate'] as bool? ?? false,
|
||||
priceCents: (m['price_cents'] as num?)?.toInt() ?? 0,
|
||||
currency: m['currency'] as String? ?? 'CNY',
|
||||
period: m['period'] as String? ?? 'month',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// usage_point.dart — 单日用量(GET /v1/usage?days=N 的一条)。
|
||||
class UsagePoint {
|
||||
const UsagePoint({
|
||||
required this.date,
|
||||
this.bytesUp = 0,
|
||||
this.bytesDown = 0,
|
||||
this.minutesUsed = 0,
|
||||
});
|
||||
|
||||
/// YYYY-MM-DD(UTC)。
|
||||
final String date;
|
||||
final int bytesUp;
|
||||
final int bytesDown;
|
||||
final int minutesUsed;
|
||||
|
||||
int get bytesTotal => bytesUp + bytesDown;
|
||||
double get gbTotal => bytesTotal / (1024 * 1024 * 1024);
|
||||
|
||||
factory UsagePoint.fromJson(Map<String, dynamic> m) => UsagePoint(
|
||||
date: m['date'] as String? ?? '',
|
||||
bytesUp: (m['bytes_up'] as num?)?.toInt() ?? 0,
|
||||
bytesDown: (m['bytes_down'] as num?)?.toInt() ?? 0,
|
||||
minutesUsed: (m['minutes_used'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// account_api.dart — 账户域端点封装(受 JWT 保护,走 ApiClient)。
|
||||
//
|
||||
// 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、
|
||||
// /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。
|
||||
import '../models/device.dart';
|
||||
import '../models/me.dart';
|
||||
import '../models/plan.dart';
|
||||
import '../models/usage_point.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 兑换结果(POST /v1/redeem)。
|
||||
class RedeemResult {
|
||||
const RedeemResult({
|
||||
required this.plan,
|
||||
required this.durationDays,
|
||||
this.idempotent = false,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String plan;
|
||||
final int durationDays;
|
||||
final bool idempotent;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
factory RedeemResult.fromJson(Map<String, dynamic> m) {
|
||||
final exp = m['expires_at'] as String?;
|
||||
return RedeemResult(
|
||||
plan: m['plan'] as String? ?? '',
|
||||
durationDays: (m['duration_days'] as num?)?.toInt() ?? 0,
|
||||
idempotent: m['idempotent'] as bool? ?? false,
|
||||
expiresAt: (exp != null && exp.isNotEmpty) ? DateTime.tryParse(exp) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountApi {
|
||||
AccountApi(this._c);
|
||||
|
||||
final ApiClient _c;
|
||||
|
||||
/// GET /v1/me — 账户聚合视图。
|
||||
Future<Me> me() async => Me.fromJson(await _c.getJson('/v1/me'));
|
||||
|
||||
/// GET /v1/plans — 套餐定义。
|
||||
Future<List<Plan>> plans() async {
|
||||
final body = await _c.getJson('/v1/plans');
|
||||
final raw = (body['plans'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => Plan.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// GET /v1/me/devices — 已登录设备列表。
|
||||
Future<List<Device>> devices() async {
|
||||
final body = await _c.getJson('/v1/me/devices');
|
||||
final raw = (body['devices'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => Device.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// DELETE /v1/me/devices/{uuid} — 移除设备。
|
||||
Future<void> removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid');
|
||||
|
||||
/// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。
|
||||
Future<List<UsagePoint>> usage({int days = 7}) async {
|
||||
final body = await _c.getJson('/v1/usage?days=$days');
|
||||
final raw = (body['points'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => UsagePoint.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// POST /v1/redeem — 兑换码。
|
||||
Future<RedeemResult> redeem(String code) async =>
|
||||
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
|
||||
|
||||
/// POST /v1/ads/unlock — 看广告解锁今日免费额度。
|
||||
Future<void> adUnlock({required String deviceId, required String adToken}) =>
|
||||
_c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// api_client.dart — 统一鉴权 HTTP 客户端。
|
||||
//
|
||||
// 职责:为受 JWT 保护的端点(/v1/me、/v1/me/devices、/v1/plans、/v1/usage、
|
||||
// /v1/redeem、/v1/ads/unlock 等)提供 GET/POST/DELETE 封装,自动:
|
||||
// - 注入 Authorization: Bearer <access token>
|
||||
// - 401 时调用 [refresh] 刷新令牌,成功则用新令牌重试一次
|
||||
// - 错误统一包装为 AuthApiException(message_zh/en),UI 复用既有错误模型
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'auth_api.dart' show AuthApiException;
|
||||
|
||||
/// 取当前 access token(可空表示未登录)。
|
||||
typedef TokenGetter = String? Function();
|
||||
|
||||
/// 触发刷新;成功返回 true(调用方应已写入新令牌)。
|
||||
typedef TokenRefresher = Future<bool> Function();
|
||||
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required this.baseUrl,
|
||||
required this.getToken,
|
||||
required this.refresh,
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final String baseUrl;
|
||||
final TokenGetter getToken;
|
||||
final TokenRefresher refresh;
|
||||
final http.Client _client;
|
||||
|
||||
/// GET → 解析为 JSON 对象。
|
||||
Future<Map<String, dynamic>> getJson(String path) async =>
|
||||
_decodeObj(await _send(() => _req('GET', path)));
|
||||
|
||||
/// POST → 解析为 JSON 对象(空响应体返回空 map)。
|
||||
Future<Map<String, dynamic>> postJson(String path, [Map<String, dynamic>? body]) async =>
|
||||
_decodeObj(await _send(() => _req('POST', path, body)));
|
||||
|
||||
/// DELETE(无响应体)。
|
||||
Future<void> delete(String path) async {
|
||||
await _send(() => _req('DELETE', path));
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送 + 401 自动刷新重试一次;非 2xx 抛 AuthApiException。
|
||||
Future<http.Response> _send(Future<http.Response> Function() build) async {
|
||||
http.Response resp;
|
||||
try {
|
||||
resp = await build();
|
||||
if (resp.statusCode == 401 && await refresh()) {
|
||||
resp = await build();
|
||||
}
|
||||
} on AuthApiException {
|
||||
rethrow;
|
||||
} on Exception catch (e) {
|
||||
throw AuthApiException(
|
||||
statusCode: -1,
|
||||
messageZh: '网络请求失败,请检查连接后重试',
|
||||
messageEn: 'Network error: $e',
|
||||
);
|
||||
}
|
||||
if (resp.statusCode < 200 || resp.statusCode >= 300) _throwFromResponse(resp);
|
||||
return resp;
|
||||
}
|
||||
|
||||
Future<http.Response> _req(String method, String path, [Map<String, dynamic>? body]) {
|
||||
final uri = Uri.parse('$baseUrl$path');
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
final t = getToken();
|
||||
if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t';
|
||||
final Future<http.Response> f;
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
f = _client.get(uri, headers: headers);
|
||||
case 'DELETE':
|
||||
f = _client.delete(uri, headers: headers);
|
||||
default:
|
||||
f = _client.post(uri, headers: headers, body: body == null ? null : jsonEncode(body));
|
||||
}
|
||||
return f.timeout(const Duration(seconds: 15));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeObj(http.Response r) {
|
||||
if (r.body.isEmpty) return <String, dynamic>{};
|
||||
return jsonDecode(r.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Never _throwFromResponse(http.Response resp) {
|
||||
String zh = '操作失败 (HTTP ${resp.statusCode})';
|
||||
String en = 'Request failed (HTTP ${resp.statusCode})';
|
||||
try {
|
||||
final b = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
zh = b['message_zh'] as String? ?? zh;
|
||||
en = b['message_en'] as String? ?? en;
|
||||
} catch (_) {}
|
||||
throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en);
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// api_config.dart — 控制面 API 基址单源。
|
||||
//
|
||||
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
|
||||
// 由 --dart-define=PANGOLIN_API_URL 注入(默认本地 8080)。
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
// account_providers.dart — 账户域 Riverpod 装配。
|
||||
//
|
||||
// 此处只做依赖装配:把 authProvider 的令牌/刷新接到统一 ApiClient,
|
||||
// 再产出 AccountApi。具体数据 provider(meProvider/devicesProvider/usage…)
|
||||
// 在后续阶段加入本文件。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/account_api.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/api_config.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 统一鉴权 HTTP 客户端:令牌取自 authProvider,401 时触发 AuthNotifier.refresh。
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
return ApiClient(
|
||||
baseUrl: kApiBaseUrl,
|
||||
getToken: () => ref.read(authProvider).accessToken,
|
||||
refresh: () => ref.read(authProvider.notifier).refresh(),
|
||||
);
|
||||
});
|
||||
|
||||
/// 账户域端点封装。
|
||||
final accountApiProvider = Provider<AccountApi>(
|
||||
(ref) => AccountApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
// auth_provider.dart — 认证状态(JWT 令牌生命周期)
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/api_config.dart';
|
||||
import '../services/auth_api.dart';
|
||||
import '../services/token_store.dart';
|
||||
|
||||
@@ -23,11 +24,14 @@ class AuthState {
|
||||
// ── 状态机 ───────────────────────────────────────────────────────────
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier(this._store) : super(const AuthState(isLoading: true)) {
|
||||
AuthNotifier(this._store, {AuthApi? api})
|
||||
: _api = api ?? AuthApi(baseUrl: kApiBaseUrl),
|
||||
super(const AuthState(isLoading: true)) {
|
||||
_loadFromStore();
|
||||
}
|
||||
|
||||
final TokenStore _store;
|
||||
final AuthApi _api;
|
||||
|
||||
Future<void> _loadFromStore() async {
|
||||
try {
|
||||
@@ -48,6 +52,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
state = AuthState(accessToken: tokens.accessToken);
|
||||
}
|
||||
|
||||
/// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。
|
||||
/// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。
|
||||
Future<bool> refresh() async {
|
||||
final rt = await _store.loadRefreshToken();
|
||||
if (rt == null || rt.isEmpty) return false;
|
||||
try {
|
||||
await saveTokens(await _api.refresh(rt));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录:清除本地令牌。
|
||||
Future<void> logout() async {
|
||||
await _store.clear();
|
||||
|
||||
@@ -26,6 +26,10 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
Future<void> _shoot(WidgetTester tester, NavView view, String name) async {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// api_client_test.dart — ApiClient 鉴权 + 401 自动刷新重试。
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiClient', () {
|
||||
test('GET 附带 Bearer 并解析 JSON', () async {
|
||||
late String seenAuth;
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => 'tok1',
|
||||
refresh: () async => false,
|
||||
client: MockClient((req) async {
|
||||
seenAuth = req.headers['Authorization'] ?? '';
|
||||
return http.Response('{"email":"a@b.c"}', 200);
|
||||
}),
|
||||
);
|
||||
final body = await c.getJson('/v1/me');
|
||||
expect(seenAuth, 'Bearer tok1');
|
||||
expect(body['email'], 'a@b.c');
|
||||
});
|
||||
|
||||
test('401 → refresh 成功 → 用新令牌重试一次', () async {
|
||||
var token = 'old';
|
||||
var calls = 0;
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => token,
|
||||
refresh: () async {
|
||||
token = 'new';
|
||||
return true;
|
||||
},
|
||||
client: MockClient((req) async {
|
||||
calls++;
|
||||
if (req.headers['Authorization'] == 'Bearer old') {
|
||||
return http.Response('{"message_zh":"expired"}', 401);
|
||||
}
|
||||
return http.Response('{"ok":true}', 200);
|
||||
}),
|
||||
);
|
||||
final body = await c.getJson('/v1/me');
|
||||
expect(calls, 2); // 首次 401 + 刷新后重试
|
||||
expect(body['ok'], true);
|
||||
});
|
||||
|
||||
test('401 → refresh 失败 → 抛 AuthApiException(401)', () async {
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => 'old',
|
||||
refresh: () async => false,
|
||||
client: MockClient((req) async => http.Response('{"message_zh":"expired"}', 401)),
|
||||
);
|
||||
expect(
|
||||
() => c.getJson('/v1/me'),
|
||||
throwsA(isA<AuthApiException>().having((e) => e.statusCode, 'status', 401)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,10 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,10 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
// ── 辅助:带 stub 覆盖的 ProviderContainer ──────────────────────
|
||||
|
||||
Reference in New Issue
Block a user