feat(client): P3 账号/套餐视角/配额接真(#6 6C)
- 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:
@@ -28,7 +28,8 @@ class Me {
|
||||
final int devicesUsed;
|
||||
final int devicesMax;
|
||||
|
||||
/// 今日额度上限(分钟);null = 不限(pro/team)。
|
||||
/// 今日**剩余**额度(分钟);null = 不限(pro/team)。
|
||||
/// 后端已算好 = 套餐每日上限 − 今日已用。
|
||||
final int? quotaTodayMin;
|
||||
|
||||
/// 今日已用流量(GB)。
|
||||
|
||||
@@ -5,13 +5,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/responsive/form_factor.dart';
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/account_providers.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/navigation_provider.dart';
|
||||
import '../widgets/account_screens.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
|
||||
const String kDemoEmail = 'me@pangolin.vpn';
|
||||
/// 格式化到期日为 YYYY-MM-DD(本地时区)。
|
||||
String _fmtDate(DateTime d) {
|
||||
final l = d.toLocal();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${l.year}-${two(l.month)}-${two(l.day)}';
|
||||
}
|
||||
|
||||
class AccountPage extends ConsumerWidget {
|
||||
const AccountPage({super.key, required this.isWide});
|
||||
@@ -22,6 +28,9 @@ class AccountPage extends ConsumerWidget {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final isFree = ref.watch(isFreePlanProvider);
|
||||
final me = ref.watch(meProvider).valueOrNull;
|
||||
final email = (me?.email.isNotEmpty ?? false) ? me!.email : '—';
|
||||
final expiresLabel = me?.expiresAt != null ? _fmtDate(me!.expiresAt!) : '';
|
||||
final lang = ref.watch(localeProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final pad = isWide ? 28.0 : 20.0;
|
||||
@@ -39,12 +48,12 @@ class AccountPage extends ConsumerWidget {
|
||||
final body = ListView(
|
||||
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
|
||||
children: [
|
||||
_PlanBanner(t: t, isFree: isFree, onUpgrade: () => open(NavView.plans, PlansScreen(t: t))),
|
||||
_PlanBanner(t: t, isFree: isFree, email: email, expiresLabel: expiresLabel, onUpgrade: () => open(NavView.plans, PlansScreen(t: t))),
|
||||
const SizedBox(height: 18),
|
||||
// 账户信息
|
||||
_SectionLabel(text: t.accInfoTitle),
|
||||
_Card(children: [
|
||||
_InfoRow(icon: PangolinIcons.mail, title: t.accEmail, value: kDemoEmail, trailing: _changeHint(c, t)),
|
||||
_InfoRow(icon: PangolinIcons.mail, title: t.accEmail, value: email, trailing: _changeHint(c, t)),
|
||||
Divider(height: 1, color: c.border),
|
||||
_InfoRow(icon: PangolinIcons.lock, title: t.accPassword, value: '••••••••••', trailing: _changeHint(c, t)),
|
||||
]),
|
||||
@@ -79,20 +88,7 @@ class AccountPage extends ConsumerWidget {
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.shield,
|
||||
title: t.protocol,
|
||||
trailing: Text('WireGuard', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 演示:免费版视角开关
|
||||
_Card(children: [
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.clock,
|
||||
title: lang == AppLang.zh ? '演示:免费版视角' : 'Demo: free-tier view',
|
||||
subtitle: lang == AppLang.zh ? '切换额度卡与套餐横幅' : 'Toggles quota UI & plan banner',
|
||||
trailing: _PangolinSwitch(
|
||||
value: isFree,
|
||||
onChanged: (v) => ref.read(isFreePlanProvider.notifier).state = v,
|
||||
),
|
||||
trailing: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
@@ -115,9 +111,17 @@ class AccountPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _PlanBanner extends StatelessWidget {
|
||||
const _PlanBanner({required this.t, required this.isFree, required this.onUpgrade});
|
||||
const _PlanBanner({
|
||||
required this.t,
|
||||
required this.isFree,
|
||||
required this.email,
|
||||
required this.expiresLabel,
|
||||
required this.onUpgrade,
|
||||
});
|
||||
final AppText t;
|
||||
final bool isFree;
|
||||
final String email;
|
||||
final String expiresLabel;
|
||||
final VoidCallback onUpgrade;
|
||||
|
||||
@override
|
||||
@@ -141,7 +145,7 @@ class _PlanBanner extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(kDemoEmail, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
Text(email, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 2),
|
||||
@@ -173,9 +177,9 @@ class _PlanBanner extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(kDemoEmail, style: const TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
Text(email, style: const TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${t.proMember} · 2026-12-31',
|
||||
Text(expiresLabel.isEmpty ? t.proMember : '${t.proMember} · $expiresLabel',
|
||||
style: TextStyle(color: PangolinColors.white.withOpacity(0.85), fontSize: 12)),
|
||||
])),
|
||||
FilledButton(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -1,36 +1,60 @@
|
||||
// quota_controller_test.dart — 免费额度状态机
|
||||
// quota_controller_test.dart — 免费额度状态(派生自 me/plans)
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/services/token_store.dart';
|
||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||
import 'package:pangolin_vpn/state/quota_provider.dart';
|
||||
|
||||
// 避免触碰真实 FlutterSecureStorage 插件(单测无平台)。
|
||||
class _NullTokenStore implements TokenStore {
|
||||
const _NullTokenStore();
|
||||
@override
|
||||
Future<void> saveTokens({required String access, required String refresh}) async {}
|
||||
@override
|
||||
Future<String?> loadAccessToken() async => null;
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
ProviderContainer _container() => ProviderContainer(
|
||||
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('默认值:10 分钟总额 / 6 分钟剩余 / 未解锁', () {
|
||||
final ctl = QuotaController();
|
||||
expect(ctl.state.totalMinutes, 10);
|
||||
expect(ctl.state.remainingMinutes, 6);
|
||||
expect(ctl.state.adUnlocked, false);
|
||||
// 纯状态逻辑(与数据源无关)。
|
||||
group('FreeQuotaState', () {
|
||||
test('progress = 剩余/总额', () {
|
||||
expect(const FreeQuotaState(totalMinutes: 10, remainingMinutes: 5).progress,
|
||||
closeTo(0.5, 1e-9));
|
||||
});
|
||||
test('isLow:剩余 ≤3 分钟为真', () {
|
||||
expect(const FreeQuotaState(remainingMinutes: 3).isLow, true);
|
||||
expect(const FreeQuotaState(remainingMinutes: 4).isLow, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('progress = 剩余/总额', () {
|
||||
final ctl = QuotaController(const FreeQuotaState(totalMinutes: 10, remainingMinutes: 5));
|
||||
expect(ctl.state.progress, closeTo(0.5, 1e-9));
|
||||
});
|
||||
// 未登录默认态:总额 10 / 剩余 10 / 未解锁;watchAd 本地置位。
|
||||
group('quotaProvider', () {
|
||||
test('默认 10/10 未解锁', () {
|
||||
final c = _container();
|
||||
addTearDown(c.dispose);
|
||||
final q = c.read(quotaProvider);
|
||||
expect(q.totalMinutes, 10);
|
||||
expect(q.remainingMinutes, 10);
|
||||
expect(q.adUnlocked, false);
|
||||
});
|
||||
|
||||
test('isLow:剩余 ≤3 分钟为真', () {
|
||||
expect(const FreeQuotaState(remainingMinutes: 3).isLow, true);
|
||||
expect(const FreeQuotaState(remainingMinutes: 4).isLow, false);
|
||||
});
|
||||
|
||||
test('watchAd 解锁今日使用', () {
|
||||
final ctl = QuotaController();
|
||||
expect(ctl.state.adUnlocked, false);
|
||||
ctl.watchAd();
|
||||
expect(ctl.state.adUnlocked, true);
|
||||
});
|
||||
|
||||
test('reset 回到未解锁初始态', () {
|
||||
final ctl = QuotaController()..watchAd();
|
||||
ctl.reset();
|
||||
expect(ctl.state.adUnlocked, false);
|
||||
expect(ctl.state.remainingMinutes, 6);
|
||||
test('watchAd 解锁今日使用', () {
|
||||
final c = _container();
|
||||
addTearDown(c.dispose);
|
||||
c.read(quotaProvider.notifier).watchAd();
|
||||
expect(c.read(quotaProvider).adUnlocked, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user