diff --git a/client/lib/models/me.dart b/client/lib/models/me.dart index 9541347..6165933 100644 --- a/client/lib/models/me.dart +++ b/client/lib/models/me.dart @@ -28,7 +28,8 @@ class Me { final int devicesUsed; final int devicesMax; - /// 今日额度上限(分钟);null = 不限(pro/team)。 + /// 今日**剩余**额度(分钟);null = 不限(pro/team)。 + /// 后端已算好 = 套餐每日上限 − 今日已用。 final int? quotaTodayMin; /// 今日已用流量(GB)。 diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 2188129..ec8cda9 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -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( diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index c6caa4e..8d68333 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -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((ref) { final accountApiProvider = Provider( (ref) => AccountApi(ref.watch(apiClientProvider)), ); + +// ── 数据 provider ─────────────────────────────────────────────────────────── + +/// 当前账户聚合视图(GET /v1/me)。登录态变化自动重取;未登录返回默认免费档。 +class MeNotifier extends AsyncNotifier { + @override + Future build() async { + final auth = ref.watch(authProvider); + if (!auth.isLoggedIn) return const Me(email: '', plan: 'free'); + return ref.read(accountApiProvider).me(); + } + + /// 手动刷新(如连接/兑换后)。 + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(accountApiProvider).me()); + } +} + +final meProvider = AsyncNotifierProvider(MeNotifier.new); + +/// 套餐定义(GET /v1/plans)。未登录返回空。 +final plansProvider = FutureProvider>((ref) async { + if (!ref.watch(authProvider).isLoggedIn) return const []; + return ref.read(accountApiProvider).plans(); +}); + +/// 最近 N 天用量(GET /v1/usage?days=N)。未登录返回空。 +final usageProvider = FutureProvider.family, int>((ref, days) async { + if (!ref.watch(authProvider).isLoggedIn) return const []; + return ref.read(accountApiProvider).usage(days: days); +}); diff --git a/client/lib/state/app_providers.dart b/client/lib/state/app_providers.dart index 3ed9aab..509941d 100644 --- a/client/lib/state/app_providers.dart +++ b/client/lib/state/app_providers.dart @@ -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((ref) => AppLang.zh); @@ -18,5 +19,9 @@ final appTextProvider = Provider((ref) { /// 主题模式。默认跟随系统;设置页可显式切深色。 final themeModeProvider = StateProvider((ref) => ThemeMode.system); -/// 演示:是否「免费版视角」。免费版才显示额度卡与免费横幅。 -final isFreePlanProvider = StateProvider((ref) => true); +/// 是否免费档——派生自真实账户(meProvider.plan == 'free')。 +/// 免费版才显示额度卡与免费横幅;加载中默认按免费处理。 +final isFreePlanProvider = Provider((ref) { + final me = ref.watch(meProvider).valueOrNull; + return me?.isFree ?? true; +}); diff --git a/client/lib/state/quota_provider.dart b/client/lib/state/quota_provider.dart index 0d49d41..fe2ab24 100644 --- a/client/lib/state/quota_provider.dart +++ b/client/lib/state/quota_provider.dart @@ -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 { - 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( - (ref) => QuotaController(), + (ref) => QuotaController(ref), ); diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index 024b923..39f05cf 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/quota_unlocked_dark.png b/client/test/golden/goldens/quota_unlocked_dark.png index ca46693..444c978 100644 Binary files a/client/test/golden/goldens/quota_unlocked_dark.png and b/client/test/golden/goldens/quota_unlocked_dark.png differ diff --git a/client/test/golden/goldens/quota_unlocked_light.png b/client/test/golden/goldens/quota_unlocked_light.png index 2ac71ce..d5003da 100644 Binary files a/client/test/golden/goldens/quota_unlocked_light.png and b/client/test/golden/goldens/quota_unlocked_light.png differ diff --git a/client/test/unit/quota_controller_test.dart b/client/test/unit/quota_controller_test.dart index 3b4df65..0ab7b59 100644 --- a/client/test/unit/quota_controller_test.dart +++ b/client/test/unit/quota_controller_test.dart @@ -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 saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future 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); + }); }); }