feat(client): 免费版连接倒计时 + 到点切断 + 灰按钮 + 看广告加时(#21 前端)

四端共享 Dart 实现,配合 #21 后端账户级卡控:

- me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。
- quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽
  + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。
- connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器
  每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted);
  倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。
- connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。
- quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。
- ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。
- l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。
- 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟);
  额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。
- 文档: docs/free-quota-ad.html + 登记 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-02 00:33:21 +08:00
parent e023fb6579
commit b461a476a2
22 changed files with 661 additions and 93 deletions
+59 -8
View File
@@ -22,6 +22,7 @@ import '../services/device_identity.dart';
import 'app_providers.dart';
import 'auth_provider.dart';
import 'nodes_provider.dart';
import 'quota_provider.dart';
import 'settings_provider.dart';
// 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。
@@ -35,7 +36,12 @@ enum VpnPhase { off, connecting, on }
// ── 连接状态快照 ──────────────────────────────────────────────────
class ConnectionState {
const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error});
const ConnectionState({
required this.phase,
this.elapsed = Duration.zero,
this.error,
this.freeCountdown,
});
final VpnPhase phase;
final Duration elapsed;
@@ -43,18 +49,27 @@ class ConnectionState {
/// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
final String? error;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
/// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。
/// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。
final Duration? freeCountdown;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) =>
ConnectionState(
phase: phase ?? this.phase,
elapsed: elapsed ?? this.elapsed,
freeCountdown: freeCountdown ?? this.freeCountdown,
);
@override
bool operator ==(Object other) =>
other is ConnectionState &&
other.phase == phase &&
other.elapsed == elapsed &&
other.error == error;
other.error == error &&
other.freeCountdown == freeCountdown;
@override
int get hashCode => Object.hash(phase, elapsed, error);
int get hashCode => Object.hash(phase, elapsed, error, freeCountdown);
}
// ── 连通看门狗 ─────────────────────────────────────────────────────
@@ -140,6 +155,9 @@ class ConnectionController extends StateNotifier<ConnectionState> {
ConnectApi? _api;
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
Node? _connectedNode;
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec 已用秒」倒计时,
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
int? _freeRemainingSec;
// ── 公有 API ───────────────────────────────────────────────────
@@ -170,6 +188,10 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_userDisconnect = false;
_lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位)
_lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位)
// 免费版:锁定本次连接可用的剩余额度(账户共享,权威取自 me)。会员为 null 不倒计时。
_freeRemainingSec = _ref.read(isFreePlanProvider)
? _ref.read(quotaProvider).remainingMinutes * 60
: null;
state = const ConnectionState(phase: VpnPhase.connecting);
final node = _ref.read(effectiveNodeProvider);
@@ -202,6 +224,12 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (mounted) state = const ConnectionState(phase: VpnPhase.off);
return;
}
// 免费额度已用完(服务端兜底):本地置耗尽 → 按钮灰化、点击弹广告/升级。回 off。
if (e.code == 'QUOTA_EXHAUSTED') {
_ref.read(quotaProvider.notifier).markExhausted();
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
return;
}
// 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
} catch (e) {
@@ -397,18 +425,41 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed());
}
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准);免费版顺带推算
/// 倒计时,归零即自动切断。倒计时用墙上时钟,后台/锁屏漏跳也会在回前台补上、准时切。
void _refreshElapsed() {
final at = _connectedAt;
if (mounted && state.phase == VpnPhase.on && at != null) {
state = state.copyWith(elapsed: _now().difference(at));
if (!mounted || state.phase != VpnPhase.on || at == null) return;
final elapsed = _now().difference(at);
Duration? countdown;
final capSec = _freeRemainingSec;
if (capSec != null) {
final leftSec = capSec - elapsed.inSeconds;
if (leftSec <= 0) {
unawaited(_onFreeQuotaExhausted());
return;
}
countdown = Duration(seconds: leftSec);
}
state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown);
}
/// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。
Future<void> _onFreeQuotaExhausted() async {
_freeRemainingSec = null;
_userDisconnect = true; // 视为主动断开,不触发「节点异常」
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
_ref.read(quotaProvider.notifier).markExhausted();
logLine('Quota', 'free daily minutes used up → auto disconnect');
await _disconnect();
}
void _stopElapsed() {
_elapsed?.cancel();
_elapsed = null;
_connectedAt = null;
_freeRemainingSec = null;
}
@override