feat(client): P3 账号/套餐视角/配额接真(#6 6C)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

- account_providers:新增 meProvider(GET /v1/me,登录态变化自动重取)、
  plansProvider、usageProvider(family by days)。
- isFreePlanProvider 改为派生自 meProvider.plan(去掉可写演示开关)。
- quotaProvider:总额取 plans free.daily_minutes,今日剩余取 me.quota_today_min
  (后端已算好);adUnlocked 保留本地态(ad SDK 未接)。
- account_page:邮箱/套餐/到期接 me;删「演示:免费版视角」开关 + kDemoEmail;
  协议标签 WireGuard→REALITY/Hysteria2。
- 重写 quota_controller_test 为 provider 驱动;account/quota golden 重生成。

flutter analyze 0 error;113 tests passed。统计页(weekly/月流量/延迟)随 P4
一并接(其均延迟依赖 P4 实测探针)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-18 23:51:28 +08:00
parent 3d87bffbe9
commit bb39b36d84
9 changed files with 151 additions and 63 deletions
+35
View File
@@ -5,6 +5,9 @@
// 在后续阶段加入本文件。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/me.dart';
import '../models/plan.dart';
import '../models/usage_point.dart';
import '../services/account_api.dart';
import '../services/api_client.dart';
import '../services/api_config.dart';
@@ -23,3 +26,35 @@ final apiClientProvider = Provider<ApiClient>((ref) {
final accountApiProvider = Provider<AccountApi>(
(ref) => AccountApi(ref.watch(apiClientProvider)),
);
// ── 数据 provider ───────────────────────────────────────────────────────────
/// 当前账户聚合视图(GET /v1/me)。登录态变化自动重取;未登录返回默认免费档。
class MeNotifier extends AsyncNotifier<Me> {
@override
Future<Me> build() async {
final auth = ref.watch(authProvider);
if (!auth.isLoggedIn) return const Me(email: '', plan: 'free');
return ref.read(accountApiProvider).me();
}
/// 手动刷新(如连接/兑换后)。
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
}
}
final meProvider = AsyncNotifierProvider<MeNotifier, Me>(MeNotifier.new);
/// 套餐定义(GET /v1/plans)。未登录返回空。
final plansProvider = FutureProvider<List<Plan>>((ref) async {
if (!ref.watch(authProvider).isLoggedIn) return const [];
return ref.read(accountApiProvider).plans();
});
/// 最近 N 天用量(GET /v1/usage?days=N)。未登录返回空。
final usageProvider = FutureProvider.family<List<UsagePoint>, int>((ref, days) async {
if (!ref.watch(authProvider).isLoggedIn) return const [];
return ref.read(accountApiProvider).usage(days: days);
});
+7 -2
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../l10n/strings_en.dart';
import '../l10n/strings_zh.dart';
import 'account_providers.dart';
/// 当前语言(单显)。设置/账户页段控切换。
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
@@ -18,5 +19,9 @@ final appTextProvider = Provider<AppText>((ref) {
/// 主题模式。默认跟随系统;设置页可显式切深色。
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
/// 演示:是否免费版视角」。免费版才显示额度卡与免费横幅
final isFreePlanProvider = StateProvider<bool>((ref) => true);
/// 是否免费档——派生自真实账户(meProvider.plan == 'free')
/// 免费版才显示额度卡与免费横幅;加载中默认按免费处理。
final isFreePlanProvider = Provider<bool>((ref) {
final me = ref.watch(meProvider).valueOrNull;
return me?.isFree ?? true;
});
+31 -12
View File
@@ -1,15 +1,18 @@
// quota_provider.dart — 免费版每日额度状态(Riverpod,mock 数据)
// quota_provider.dart — 免费版每日额度状态(Riverpod)
//
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,本地仅展示
// 这里以 mock 实现 UI 与数据层解耦的接口形态:剩余分钟 + 是否已看广告解锁。
// 接 API 时只替换本通知器内部,UI 不动。
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端。
// 总额度取自 plans 的 free.daily_minutes,今日剩余取自 me.quota_today_min
// (后端已算好 = 上限 − 今日已用)。adUnlocked 为本地会话态(看广告需 ad SDK,
// 尚未接入,保留本地乐观置位)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'account_providers.dart';
/// 免费额度快照。
class FreeQuotaState {
const FreeQuotaState({
this.totalMinutes = 10,
this.remainingMinutes = 6,
this.remainingMinutes = 10,
this.adUnlocked = false,
});
@@ -38,16 +41,32 @@ class FreeQuotaState {
}
class QuotaController extends StateNotifier<FreeQuotaState> {
QuotaController([FreeQuotaState? initial])
: super(initial ?? const FreeQuotaState());
QuotaController(this._ref) : super(const FreeQuotaState()) {
_sync();
// me / plans 任一就绪或变化都重算。
_ref.listen(meProvider, (_, __) => _sync());
_ref.listen(plansProvider, (_, __) => _sync());
}
/// 观看激励视频后解锁今日使用。
final Ref _ref;
void _sync() {
final me = _ref.read(meProvider).valueOrNull;
final plans = _ref.read(plansProvider).valueOrNull;
var total = 10; // §7 默认免费 10 分钟,plans 就绪后以其为准
if (plans != null) {
for (final p in plans) {
if (p.code == 'free' && p.dailyMinutes != null) total = p.dailyMinutes!;
}
}
final remaining = (me?.quotaTodayMin ?? total).clamp(0, total);
state = state.copyWith(totalMinutes: total, remainingMinutes: remaining);
}
/// 观看激励视频后解锁今日使用(本地乐观;真实 ad 校验待 ad SDK 接入)。
void watchAd() => state = state.copyWith(adUnlocked: true);
/// 演示重置(回到未解锁)。
void reset() => state = const FreeQuotaState();
}
final quotaProvider = StateNotifierProvider<QuotaController, FreeQuotaState>(
(ref) => QuotaController(),
(ref) => QuotaController(ref),
);