Files
pangolin/client/lib/screens/connect_page.dart
T
wangjia 0736014552 fix(client): 延迟统一用 node.ping(client→数据面 RTT),连接页与节点列表同源一致
撤掉连接页连接态的内核 urltest —— 它与节点列表的 node.ping 是两套口径、两处显示不一致。
统一全部用 node.ping(直接 TCP 握手 节点:443),连接页显示生效节点的 ping、节点列表显示
各节点 ping,永远同一个数。连接态 TUN 测不到新值则保留上次实测(_measure 已改保留)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:28:39 +08:00

356 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/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,
};
final button = ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
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, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
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,
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, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
),
],
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)),
]),
);
}
}