Files
pangolin/client/lib/screens/connect_page.dart
T
wangjia ce5155d4ca
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
feat(client): P5 连接页实时速度 + 连接错误冒泡 UI(#6 6E,并入 #4)
- vpnStatsProvider:暴露内核 statsStream(上/下行瞬时速率);连接页速度行
  下/上行改用实时数据(B/s→Mb/s),不再硬编码 86.4/12.1;延迟用实测节点 ping。
- ConnectionState 加 error 字段;_connect 失败不再静默回 off,而是按语言
  冒泡 ConnectApiException 文案;节点未就绪(无 uuid)给出提示并删除旧 1.2s
  mock 连接路径。
- 连接页 caption 下/桌面 pill 上方显示错误文案(红)。
- 重写 connection_controller_test 适配(无网络:未就绪→off+error)。

flutter analyze 0 error;114 tests passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:09:31 +08:00

343 lines
13 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 '../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;
final down = _mbps(stats?.downloadSpeed);
final up = _mbps(stats?.uploadSpeed);
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, node: node, down: down, up: up),
_CurrentNodeCard(t: t, node: node, smart: smart, 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),
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, node: node, down: down, up: up),
],
]),
),
);
}
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),
]),
),
]),
),
),
),
),
),
]);
}
}
/// 字节/秒 → Mb/s 字符串(null = 未取到,显示 —)。
String _mbps(double? bytesPerSec) =>
bytesPerSec == null ? '' : (bytesPerSec * 8 / 1000000).toStringAsFixed(1);
/// 实时速率行(连接成功时显示;来自内核 statsStream 实时数据)。
class _SpeedRow extends StatelessWidget {
const _SpeedRow({required this.t, required this.node, required this.down, required this.up});
final AppText t;
final Node node;
final String down;
final String up;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final metrics = [
(PangolinIcons.arrowDown, t.download, down, 'Mb/s'),
(PangolinIcons.arrowUp, t.upload, up, 'Mb/s'),
(PangolinIcons.zap, t.latency, node.ping > 0 ? '${node.ping}' : '', '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.showLabel,
required this.onTap,
});
final AppText t;
final Node node;
final bool smart;
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)} · ${node.pingLabel}'
: '${node.localizedSub(t.lang)} · ${node.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});
final AppText t;
final Node node;
final bool smart;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final label = smart
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.pingLabel}'
: '${node.localizedName(t.lang)} · ${node.pingLabel}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: c.surface,
border: Border.all(color: c.border),
borderRadius: BorderRadius.circular(PangolinRadius.full),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (smart) ...[Icon(PangolinIcons.zap, size: 13, color: c.accent), const SizedBox(width: 7)],
Text(label,
style: PangolinText.caption.copyWith(color: c.fg2, fontSize: 12.5, fontWeight: FontWeight.w600)),
]),
);
}
}