Files
pangolin/client/lib/screens/connect_page.dart
T
wangjia b461a476a2 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>
2026-07-02 00:33:21 +08:00

378 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// connect_page.dart — 连接页(三端:desktop 居中单列 / tablet 双栏 / mobile 单列滚动)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/responsive/form_factor.dart';
import '../util/format.dart';
import '../l10n/app_text.dart';
import '../models/node.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../state/quota_provider.dart';
import '../widgets/ad_reward_dialog.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/connect_button.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/quota_card.dart';
class ConnectPage extends ConsumerWidget {
const ConnectPage({super.key, required this.isWide, required this.onOpenNodes});
final bool isWide;
final VoidCallback onOpenNodes;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final conn = ref.watch(connectionProvider);
final node = ref.watch(effectiveNodeProvider);
final smart = ref.watch(isSmartSelectProvider);
final isFree = ref.watch(isFreePlanProvider);
final quota = ref.watch(quotaProvider);
final stats = ref.watch(vpnStatsProvider).valueOrNull;
// 账户首拉中 → 整页转圈,先拉到 me(决定免费/会员)再渲染,
// 避免「先闪免费广告、me 到了再跳会员」。me 是常驻 Notifier,只首启时转一下。
if (ref.watch(meLoadingProvider)) {
return Center(child: CircularProgressIndicator(color: c.accent, strokeWidth: 2.6));
}
final down = formatSpeed(stats?.downloadSpeed);
final up = formatSpeed(stats?.uploadSpeed);
// 延迟统一用 node.ping(直接 TCP 握手 节点:443 = client→数据面 RTT),与节点列表同源,
// 两处显示永远一致。连接态全局 TUN 测不到新值 → 保留上次断开态实测(见 nodes_provider._measure)。
final livePing = node.ping;
final pingLabel = livePing > 0 ? '${livePing}ms' : '';
final latencyValue = livePing > 0 ? '$livePing' : '';
final caption = switch (conn.phase) {
VpnPhase.off => t.capOff,
VpnPhase.connecting => t.capConnecting,
VpnPhase.on => t.capOn,
};
// 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。
final isDesktop = context.formFactor == FormFactor.desktop;
final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off);
void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop);
final button = ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
);
final captionWidget = Column(mainAxisSize: MainAxisSize.min, children: [
Text(caption, style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
if (conn.error != null) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(conn.error!,
textAlign: TextAlign.center,
style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
),
],
]);
final infoChildren = <Widget>[
if (isFree)
QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue),
_CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes),
];
final statusTrailing = Text(
conn.phase == VpnPhase.on ? t.online : t.offline,
style: PangolinText.mono.copyWith(
fontSize: 12,
color: conn.phase == VpnPhase.on ? c.success : c.fg3,
fontWeight: FontWeight.w600),
);
if (context.formFactor == FormFactor.desktop) {
// 桌面居中单列(对照 ui_kits/desktop/dapp.jsx DConnect
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40),
child: Column(mainAxisSize: MainAxisSize.min, children: [
ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
size: 176,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
),
const SizedBox(height: 24),
Text(caption,
textAlign: TextAlign.center,
style: PangolinText.display.copyWith(color: c.fg1, fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 10),
if (conn.error != null) ...[
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(conn.error!,
textAlign: TextAlign.center,
style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
),
],
_NodePill(t: t, node: node, smart: smart, pingLabel: pingLabel),
if (isFree) ...[
const SizedBox(height: 24),
SizedBox(
width: 340,
child: QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
),
],
if (conn.phase == VpnPhase.on) ...[
const SizedBox(height: 16),
_SpeedRow(t: t, down: down, up: up, latency: latencyValue),
],
]),
),
);
}
if (isWide) {
// 宽屏双栏:左大连接键 / 右信息列(额度卡→当前节点→速率)
return Column(children: [
Expanded(
child: Row(children: [
Expanded(
flex: 5,
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
button,
const SizedBox(height: 24),
captionWidget,
]),
),
),
Container(width: 1, color: c.border),
SizedBox(
width: 332,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final w in infoChildren) Padding(padding: const EdgeInsets.only(bottom: 14), child: w),
],
),
),
),
]),
),
]);
}
// 窄屏单栏(可滚动,避免窗口偏矮时底部溢出):
// 顶栏固定,内容区用 SingleChildScrollView + ConstrainedBox(minHeight) +
// IntrinsicHeight,高度足够时连接键居中,高度不足时整体滚动而非溢出。
return Column(children: [
AppTopBar(brand: t.brand, trailing: statusTrailing),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(children: [
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
button,
const SizedBox(height: 24),
captionWidget,
]),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
for (final w in infoChildren) Padding(padding: const EdgeInsets.only(bottom: 12), child: w),
]),
),
]),
),
),
),
),
),
]);
}
}
/// 实时速率行(连接成功时显示;来自内核 statsStream 实时数据)。
class _SpeedRow extends StatelessWidget {
const _SpeedRow({required this.t, required this.down, required this.up, required this.latency});
final AppText t;
final (String, String) down;
final (String, String) up;
final String latency;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final metrics = [
(PangolinIcons.arrowDown, t.download, down.$1, down.$2),
(PangolinIcons.arrowUp, t.upload, up.$1, up.$2),
(PangolinIcons.zap, t.latency, latency, 'ms'),
];
return Row(children: [
for (var i = 0; i < metrics.length; i++)
Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < metrics.length - 1 ? 12 : 0),
child: Container(
padding: const EdgeInsets.fromLTRB(13, 11, 13, 11),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(metrics[i].$1, size: 13, color: c.accent),
const SizedBox(width: 5),
Flexible(
child: Text(metrics[i].$2,
overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 11)),
),
]),
const SizedBox(height: 4),
Text.rich(TextSpan(
text: metrics[i].$3,
style: PangolinText.mono.copyWith(fontSize: 16, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' ${metrics[i].$4}', style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5))],
)),
]),
),
),
),
]);
}
}
/// 当前节点卡(智能选择时展示「智能选择 + zap」)。
class _CurrentNodeCard extends StatelessWidget {
const _CurrentNodeCard({
required this.t,
required this.node,
required this.smart,
required this.pingLabel,
required this.showLabel,
required this.onTap,
});
final AppText t;
final Node node;
final bool smart;
final String pingLabel;
final bool showLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final title = smart ? t.smartSelect : node.localizedName(t.lang);
final sub = smart
? '${node.localizedName(t.lang)} · $pingLabel'
: '${node.localizedSub(t.lang)} · $pingLabel';
return Material(
color: c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
side: BorderSide(color: c.border),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
CountryCode(code: node.code, active: true),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (showLabel)
Text(t.currentNode,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 11)),
Row(children: [
Flexible(
child: Text(title,
overflow: TextOverflow.ellipsis,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
),
if (smart) ...[const SizedBox(width: 6), Icon(PangolinIcons.zap, size: 13, color: c.accent)],
]),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
]),
),
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
]),
),
),
);
}
}
/// 桌面连接页节点胶囊([zap] 智能选择 · 节点名 · 延迟ms)。
class _NodePill extends StatelessWidget {
const _NodePill({required this.t, required this.node, required this.smart, required this.pingLabel});
final AppText t;
final Node node;
final bool smart;
final String pingLabel;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final label = smart
? '${t.smartSelect} · ${node.localizedName(t.lang)} · $pingLabel'
: '${node.localizedName(t.lang)} · $pingLabel';
return Container(
padding: const EdgeInsets.only(left: 6, right: 14, top: 5, bottom: 5),
decoration: BoxDecoration(
color: c.surface,
border: Border.all(color: c.border),
borderRadius: BorderRadius.circular(PangolinRadius.full),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
// 地域国旗(无效码回退码块);智能选择再叠一个 zap 标识。
CountryCode(code: node.code, size: 20),
const SizedBox(width: 8),
if (smart) ...[Icon(PangolinIcons.zap, size: 13, color: c.accent), const SizedBox(width: 6)],
Text(label,
style: PangolinText.caption.copyWith(color: c.fg2, fontSize: 12.5, fontWeight: FontWeight.w600)),
]),
);
}
}