Files
pangolin/client/lib/screens/connect_page.dart
T
wangjia 272eca04ce feat(client): pay v2 支付渠道 switch + 订单列表/详情端到端
购买/支付(Phase B):
- 新 SegSwitch 段控 widget(镜像 .segswitch 原子)
- 购买页重构:顶部支付渠道 switch(默认 USDT→加密货币 / 人民币→支付宝),套餐价随渠道分币种显示,删底部弹窗选方式;点购买直达支付页
- 支付页加订单信息卡(套餐/渠道/金额按币种)
- models:PayChannel + PayCatalogItem.priceUsdtMicro/priceLabel(ch)/perMonthLabel(ch)

订单(P0,全新):
- payment_api.listOrders() + orders_provider(列表 + 详情 family)
- OrdersScreen 列表→详情(状态 pill / 可复制订单号 / 继续支付·重新购买)
- 导航:NavView.orders + desktop_shell 分支 + account 入口

配套:
- l10n +19 getter(支付渠道 4 + 订单 15)×6 语言,l10n 闸绿
- 图标单源迁移 lucide_icons_flutter + pangolin_icons.dart(codegen)
- 联系渠道订正(Telegram/邮件 support@yanmeiai.com/官网) + 前端去广告&时长
- 测试:购买 switch 分币种 + 订单列表/空态/详情 + 假 API listOrders;golden 重录
- flutter analyze 0 error,flutter test 226 全绿

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-12 00:07:15 +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 '../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 '../widgets/app_top_bar.dart';
import '../widgets/connect_button.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_icons.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 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 (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 (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)),
]),
);
}
}