feat(client): P4 节点实测延迟/设备/套餐价/兑换/统计接真(#6 6D)
- latency_probe.dart:并行 TCP 握手实测各节点 per-client RTT(服务端无法代知)。 - nodes_provider:解析 /v1/nodes 的 host/port,后台测延迟回填 Node.ping;智能选择 按实测最小;去掉 8 个伪造演示节点(空列表用中性占位,不再伪造服务器)。 - Node:+host/port/copyWith/pingLabel(未测显示 —);各端 ping 显示改 pingLabel。 - account_screens:PlansScreen 接 /v1/plans(价格 priceLabel + me 标当前档); DevicesScreen 接 /v1/me/devices + 移除 DELETE + last_seen;RedeemScreen 接 /v1/redeem(成功刷新 me,失败显示后端文案)。 - stats_page:周柱接 me.weekly_gb,本月流量/时长接 usage(30),延迟用生效节点实测。 - 顺带补登记早前 log_time.dart 的删除(log.dart 重构遗漏 stage)。 flutter analyze 0 error;115 tests passed;受影响 4 golden 重生成。 本机设备高亮 + 真实 device id 随 P6(device_info_plus)落地。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -1,8 +0,0 @@
|
||||
// log_time.dart — 桌面端日志统一时间戳前缀。
|
||||
// 返回本地时间 "[HH:MM:SS.mmm] ",拼在日志行首便于排查时序。
|
||||
|
||||
String logTime() {
|
||||
final n = DateTime.now();
|
||||
String p(int v, [int w = 2]) => v.toString().padLeft(w, '0');
|
||||
return '[${p(n.hour)}:${p(n.minute)}:${p(n.second)}.${p(n.millisecond, 3)}] ';
|
||||
}
|
||||
@@ -14,10 +14,11 @@ class Node {
|
||||
this.uuid = '',
|
||||
this.tier = 'free',
|
||||
this.tag = NodeTag.none,
|
||||
this.host = '',
|
||||
this.port = 0,
|
||||
});
|
||||
|
||||
/// 服务端 UUID,用于 POST /v1/nodes/{uuid}/connect。
|
||||
/// 演示节点为空字符串。
|
||||
final String uuid;
|
||||
|
||||
/// 2 字母国家码(HK/JP/SG…),界面以码块渲染,绝不用 emoji 国旗。
|
||||
@@ -28,10 +29,29 @@ class Node {
|
||||
/// 节点层级:'free' | 'pro'。
|
||||
final String tier;
|
||||
|
||||
/// 演示延迟(ms);真实节点由探针数据填充。
|
||||
/// 客户端实测延迟(ms);0 = 尚未测得(显示 — )。
|
||||
final int ping;
|
||||
final NodeTag tag;
|
||||
|
||||
/// 节点入口 host/port(来自 /v1/nodes),用于客户端 TCP 实测延迟。
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
/// 延迟标签:测得显示「Nms」,未测显示「—」。
|
||||
String get pingLabel => ping > 0 ? '${ping}ms' : '—';
|
||||
|
||||
Node copyWith({int? ping}) => Node(
|
||||
code: code,
|
||||
nameZh: nameZh,
|
||||
nameEn: nameEn,
|
||||
ping: ping ?? this.ping,
|
||||
uuid: uuid,
|
||||
tier: tier,
|
||||
tag: tag,
|
||||
host: host,
|
||||
port: port,
|
||||
);
|
||||
|
||||
String localizedName(AppLang lang) => lang == AppLang.zh ? nameZh : nameEn;
|
||||
|
||||
/// 副标题:有标签时显示语义文字,否则用拉丁地名(两语言通用,不串语言)。
|
||||
@@ -50,14 +70,5 @@ class Node {
|
||||
/// 智能选择的哨兵选中值。
|
||||
const String kSmartNodeCode = 'AUTO';
|
||||
|
||||
/// 演示节点清单(对齐 design/ui_kits/mobile/parts.jsx 的 SERVERS)。
|
||||
const List<Node> kDemoNodes = [
|
||||
Node(code: 'HK', nameZh: '香港 · 流媒体', nameEn: 'Hong Kong', ping: 18, tag: NodeTag.streaming),
|
||||
Node(code: 'JP', nameZh: '日本 东京', nameEn: 'Tokyo', ping: 32, tag: NodeTag.p2p),
|
||||
Node(code: 'SG', nameZh: '新加坡', nameEn: 'Singapore', ping: 54, tag: NodeTag.p2p),
|
||||
Node(code: 'TW', nameZh: '台湾 台北', nameEn: 'Taipei', ping: 28),
|
||||
Node(code: 'US', nameZh: '美国 洛杉矶', nameEn: 'Los Angeles', ping: 146, tag: NodeTag.streaming),
|
||||
Node(code: 'DE', nameZh: '德国 法兰克福', nameEn: 'Frankfurt', ping: 198),
|
||||
Node(code: 'UK', nameZh: '英国 伦敦', nameEn: 'London', ping: 210),
|
||||
Node(code: 'KR', nameZh: '韩国 首尔', nameEn: 'Seoul', ping: 41),
|
||||
];
|
||||
/// 节点未就绪时的中性占位(非伪造服务器,仅加载窗口短暂展示)。
|
||||
const Node kPlaceholderNode = Node(code: '··', nameZh: '加载中', nameEn: 'Loading', ping: 0);
|
||||
|
||||
@@ -186,7 +186,7 @@ class _SpeedRow extends StatelessWidget {
|
||||
final metrics = [
|
||||
(PangolinIcons.arrowDown, t.download, '86.4', 'Mb/s'),
|
||||
(PangolinIcons.arrowUp, t.upload, '12.1', 'Mb/s'),
|
||||
(PangolinIcons.zap, t.latency, '${node.ping}', 'ms'),
|
||||
(PangolinIcons.zap, t.latency, node.ping > 0 ? '${node.ping}' : '—', 'ms'),
|
||||
];
|
||||
return Row(children: [
|
||||
for (var i = 0; i < metrics.length; i++)
|
||||
@@ -245,8 +245,8 @@ class _CurrentNodeCard extends StatelessWidget {
|
||||
final c = context.pangolin;
|
||||
final title = smart ? t.smartSelect : node.localizedName(t.lang);
|
||||
final sub = smart
|
||||
? '${node.localizedName(t.lang)} · ${node.ping}ms'
|
||||
: '${node.localizedSub(t.lang)} · ${node.ping}ms';
|
||||
? '${node.localizedName(t.lang)} · ${node.pingLabel}'
|
||||
: '${node.localizedSub(t.lang)} · ${node.pingLabel}';
|
||||
return Material(
|
||||
color: c.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -296,8 +296,8 @@ class _NodePill extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final label = smart
|
||||
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.ping}ms'
|
||||
: '${node.localizedName(t.lang)} · ${node.ping}ms';
|
||||
? '${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(
|
||||
|
||||
@@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final selected = ref.watch(selectedNodeCodeProvider);
|
||||
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? kDemoNodes);
|
||||
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? const <Node>[]);
|
||||
final smart = selected == kSmartNodeCode;
|
||||
|
||||
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode));
|
||||
@@ -184,7 +184,7 @@ class _NodeGridTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('${node.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
const SizedBox(height: 5),
|
||||
SignalBars(ping: node.ping),
|
||||
]),
|
||||
|
||||
@@ -2,28 +2,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/usage_point.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/account_providers.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/nodes_provider.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
|
||||
class StatsPage extends ConsumerWidget {
|
||||
const StatsPage({super.key, required this.isWide});
|
||||
final bool isWide;
|
||||
|
||||
static const _vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final maxV = _vals.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
// 真实数据:周柱来自 me.weekly_gb;本月流量/时长来自 usage(30);延迟为生效节点实测。
|
||||
final me = ref.watch(meProvider).valueOrNull;
|
||||
final usage = ref.watch(usageProvider(30)).valueOrNull ?? const <UsagePoint>[];
|
||||
final node = ref.watch(effectiveNodeProvider);
|
||||
|
||||
final weekly = (me?.weeklyGb.length == 7) ? me!.weeklyGb : List<double>.filled(7, 0.0);
|
||||
final maxV = weekly.fold<double>(0, (a, b) => a > b ? a : b);
|
||||
final denom = maxV <= 0 ? 1.0 : maxV;
|
||||
final monthGb = usage.fold<double>(0, (s, p) => s + p.gbTotal);
|
||||
final monthHours = usage.fold<int>(0, (s, p) => s + p.minutesUsed) / 60.0;
|
||||
// desktop 对照 dapp.jsx DStats:padding 32/8、卡 gap 14、柱 height 120·bar×86·宽 34。
|
||||
final pad = isWide ? 32.0 : 20.0;
|
||||
|
||||
final metrics = [
|
||||
(t.trafficMonth, '42.6', 'GB'),
|
||||
(t.avgPing, '29', 'ms'),
|
||||
(t.durMonth, '86.4', 'h'),
|
||||
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
|
||||
(t.avgPing, node.ping > 0 ? '${node.ping}' : '—', 'ms'),
|
||||
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
|
||||
];
|
||||
|
||||
final body = ListView(
|
||||
@@ -55,15 +66,15 @@ class StatsPage extends ConsumerWidget {
|
||||
// 柱最高 86 + 上下数值/星期标签两行 + 间距 ≈ 134,留余量 140 避免溢出。
|
||||
height: 140,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
for (var i = 0; i < _vals.length; i++)
|
||||
for (var i = 0; i < weekly.length; i++)
|
||||
Expanded(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Text('${_vals[i]}', style: PangolinText.mono.copyWith(fontSize: 10.5, color: c.fg3)),
|
||||
Text(weekly[i].toStringAsFixed(1), style: PangolinText.mono.copyWith(fontSize: 10.5, color: c.fg3)),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 34),
|
||||
child: Container(
|
||||
height: 86 * (_vals[i] / maxV),
|
||||
height: 86 * (weekly[i] / denom),
|
||||
decoration: BoxDecoration(
|
||||
color: c.accent.withOpacity(0.85),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// latency_probe.dart — 客户端实测节点延迟。
|
||||
//
|
||||
// per-client 到节点的 RTT 服务端无法代知(agent 到控制面的 RTT ≠ 用户 ping),
|
||||
// 故由客户端对各节点入口做并行 TCP 握手计时,得到真实 per-client 延迟。
|
||||
// 取多次取最小(规避抖动),失败返回 0(UI 显示 — )。
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
/// 对 [host]:[port] 做 [samples] 次 TCP 握手,返回最小耗时(ms);全失败返回 0。
|
||||
Future<int> probeLatency(
|
||||
String host,
|
||||
int port, {
|
||||
int samples = 2,
|
||||
Duration timeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
if (host.isEmpty || port <= 0) return 0;
|
||||
int best = 0;
|
||||
for (var i = 0; i < samples; i++) {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final socket = await Socket.connect(host, port, timeout: timeout);
|
||||
sw.stop();
|
||||
socket.destroy();
|
||||
final ms = sw.elapsedMilliseconds;
|
||||
if (best == 0 || ms < best) best = ms;
|
||||
} catch (_) {
|
||||
// 单次失败忽略,继续下一次采样。
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// 并行测一组 (uuid, host, port),返回 uuid→延迟(ms)。
|
||||
Future<Map<String, int>> probeAll(
|
||||
Iterable<({String uuid, String host, int port})> targets,
|
||||
) async {
|
||||
final entries = await Future.wait(targets.map((tg) async {
|
||||
final ms = await probeLatency(tg.host, tg.port);
|
||||
return MapEntry(tg.uuid, ms);
|
||||
}));
|
||||
return Map.fromEntries(entries);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// 在后续阶段加入本文件。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/device.dart';
|
||||
import '../models/me.dart';
|
||||
import '../models/plan.dart';
|
||||
import '../models/usage_point.dart';
|
||||
@@ -58,3 +59,22 @@ final usageProvider = FutureProvider.family<List<UsagePoint>, int>((ref, days) a
|
||||
if (!ref.watch(authProvider).isLoggedIn) return const [];
|
||||
return ref.read(accountApiProvider).usage(days: days);
|
||||
});
|
||||
|
||||
/// 已登录设备列表(GET /v1/me/devices)。
|
||||
class DevicesNotifier extends AsyncNotifier<List<Device>> {
|
||||
@override
|
||||
Future<List<Device>> build() async {
|
||||
if (!ref.watch(authProvider).isLoggedIn) return const [];
|
||||
return ref.read(accountApiProvider).devices();
|
||||
}
|
||||
|
||||
/// 移除设备(DELETE)后刷新列表。
|
||||
Future<void> remove(String uuid) async {
|
||||
await ref.read(accountApiProvider).removeDevice(uuid);
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
}
|
||||
|
||||
final devicesProvider =
|
||||
AsyncNotifierProvider<DevicesNotifier, List<Device>>(DevicesNotifier.new);
|
||||
|
||||
@@ -1,49 +1,65 @@
|
||||
// nodes_provider.dart — 节点清单 + 当前选择
|
||||
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
|
||||
//
|
||||
// 从 GET /v1/nodes 拉取节点列表;认证后自动刷新。
|
||||
// 未登录 / 加载中时退回演示节点(kDemoNodes)保证 UI 正常显示。
|
||||
// 从 GET /v1/nodes 拉取真实节点(含 host/port);拉取后后台对各节点做 TCP 握手
|
||||
// 实测 per-client 延迟并回填。未登录 / 失败返回空列表(不再伪造演示节点)。
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/node.dart';
|
||||
import '../services/api_config.dart';
|
||||
import '../services/latency_probe.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
// ── API base URL(由 --dart-define 注入)──────────────────────────
|
||||
|
||||
const _kApiUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
|
||||
|
||||
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
Future<List<Node>> build() async {
|
||||
ref.onDispose(() => _disposed = true);
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isLoggedIn) return kDemoNodes;
|
||||
return _fetchNodes(auth.accessToken!);
|
||||
if (!auth.isLoggedIn) return const [];
|
||||
final list = await _fetchNodes(auth.accessToken!);
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
final auth = ref.read(authProvider);
|
||||
if (!auth.isLoggedIn) {
|
||||
state = AsyncData(kDemoNodes);
|
||||
state = const AsyncData([]);
|
||||
return;
|
||||
}
|
||||
state = await AsyncValue.guard(() => _fetchNodes(auth.accessToken!));
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final list = await _fetchNodes(auth.accessToken!);
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。
|
||||
Future<void> _measure(List<Node> list) async {
|
||||
if (list.isEmpty) return;
|
||||
final pings = await probeAll(
|
||||
[for (final n in list) (uuid: n.uuid, host: n.host, port: n.port)],
|
||||
);
|
||||
if (_disposed) return;
|
||||
final updated = [for (final n in list) n.copyWith(ping: pings[n.uuid] ?? n.ping)];
|
||||
state = AsyncData(updated);
|
||||
}
|
||||
|
||||
static Future<List<Node>> _fetchNodes(String accessToken) async {
|
||||
final uri = Uri.parse('$_kApiUrl/v1/nodes');
|
||||
final uri = Uri.parse('$kApiBaseUrl/v1/nodes');
|
||||
final resp = await http.get(uri, headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
}).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (resp.statusCode != 200) return kDemoNodes;
|
||||
if (resp.statusCode != 200) return const [];
|
||||
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final rawList = body['nodes'] as List<dynamic>? ?? [];
|
||||
@@ -55,7 +71,9 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
nameZh: m['name_zh'] as String? ?? '',
|
||||
nameEn: m['name_en'] as String? ?? '',
|
||||
tier: m['tier'] as String? ?? 'free',
|
||||
ping: 0, // 延迟由探针数据填充;MVP 默认 0
|
||||
host: m['host'] as String? ?? '',
|
||||
port: (m['port'] as num?)?.toInt() ?? 0,
|
||||
ping: 0, // 由 _measure 实测回填
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@@ -64,7 +82,7 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
final nodesProvider =
|
||||
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
|
||||
|
||||
// ── 当前选中的节点 UUID;'AUTO' 表示智能选择(默认)─────────────────
|
||||
// ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)─────────────────
|
||||
|
||||
final selectedNodeCodeProvider = StateProvider<String>((ref) => kSmartNodeCode);
|
||||
|
||||
@@ -73,16 +91,18 @@ final isSmartSelectProvider = Provider<bool>(
|
||||
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
|
||||
);
|
||||
|
||||
/// 实际生效的节点:同步拉取 AsyncValue;未就绪时取 kDemoNodes 第一条。
|
||||
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
|
||||
final effectiveNodeProvider = Provider<Node>((ref) {
|
||||
final nodesAsync = ref.watch(nodesProvider);
|
||||
final nodes = nodesAsync.valueOrNull ?? kDemoNodes;
|
||||
if (nodes.isEmpty) return kDemoNodes.first;
|
||||
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
|
||||
if (nodes.isEmpty) return kPlaceholderNode;
|
||||
|
||||
final code = ref.watch(selectedNodeCodeProvider);
|
||||
if (code == kSmartNodeCode) {
|
||||
// 延迟最小者;MVP 无真实延迟时取第一条
|
||||
return nodes.reduce((a, b) => a.ping <= b.ping ? a : b);
|
||||
return nodes.reduce((a, b) {
|
||||
final pa = a.ping > 0 ? a.ping : 1 << 30;
|
||||
final pb = b.ping > 0 ? b.ping : 1 << 30;
|
||||
return pa <= pb ? a : b;
|
||||
});
|
||||
}
|
||||
return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first);
|
||||
});
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
// 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。
|
||||
// App 内无任何支付表单——购买仅引导至外部渠道。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../models/device.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/auth_api.dart';
|
||||
import '../state/account_providers.dart';
|
||||
import 'pangolin_button.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'plan_card.dart';
|
||||
@@ -43,133 +47,149 @@ class _SubScaffold extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 套餐选择 ──
|
||||
class PlansScreen extends StatelessWidget {
|
||||
/// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定;
|
||||
/// 功能清单仍取 l10n,后端无此字段)
|
||||
class PlansScreen extends ConsumerWidget {
|
||||
const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false});
|
||||
final AppText t;
|
||||
final ValueChanged<String>? onChoose;
|
||||
final VoidCallback? onBack;
|
||||
final bool embedded;
|
||||
|
||||
List<String> _feats(String code) => switch (code) {
|
||||
'free' => t.featsFree,
|
||||
'pro' => t.featsPro,
|
||||
'team' => t.featsTeam,
|
||||
_ => const [],
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final plans = [
|
||||
(id: 'free', name: t.freePlan, price: '¥0', cta: t.current, featured: false, current: true, pop: null as String?, feats: t.featsFree),
|
||||
(id: 'pro', name: t.proPlan, price: '¥25', cta: t.upgrade, featured: true, current: false, pop: t.mostPopular, feats: t.featsPro),
|
||||
(id: 'team', name: t.teamPlan, price: '¥99', cta: t.choose, featured: false, current: false, pop: null as String?, feats: t.featsTeam),
|
||||
];
|
||||
return _SubScaffold(
|
||||
title: t.choosePlan,
|
||||
onBack: onBack,
|
||||
embedded: embedded,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||
children: [
|
||||
for (final p in plans)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
|
||||
child: PlanCard(
|
||||
name: p.name, price: p.price, period: t.perMonth,
|
||||
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
|
||||
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final plansAsync = ref.watch(plansProvider);
|
||||
final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free';
|
||||
|
||||
Widget body() => plansAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (plans) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||
children: [
|
||||
for (final p in plans)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0),
|
||||
child: PlanCard(
|
||||
name: p.localizedName(t.lang),
|
||||
price: p.priceLabel(),
|
||||
period: t.perMonth,
|
||||
features: _feats(p.code),
|
||||
ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade),
|
||||
featured: p.code == 'pro',
|
||||
isCurrent: p.code == myPlan,
|
||||
popularLabel: p.code == 'pro' ? t.mostPopular : null,
|
||||
onPressed: () => onChoose?.call(p.code),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body());
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 设备管理 ──
|
||||
class DeviceItem {
|
||||
const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false});
|
||||
final String id, name, os, active;
|
||||
final IconData icon;
|
||||
final bool me;
|
||||
}
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
/// ── 设备管理 ── (真实 GET /v1/me/devices;移除 = DELETE。本机高亮待 P6 设备 id)
|
||||
class DevicesScreen extends ConsumerWidget {
|
||||
const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
||||
final AppText t;
|
||||
final VoidCallback? onBack;
|
||||
final bool embedded;
|
||||
@override
|
||||
State<DevicesScreen> createState() => _DevicesScreenState();
|
||||
}
|
||||
|
||||
class _DevicesScreenState extends State<DevicesScreen> {
|
||||
late List<DeviceItem> _devices = [
|
||||
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.t.thisDevice, me: true),
|
||||
DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'),
|
||||
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.t.lang == AppLang.zh ? '3 小时前' : '3h ago'),
|
||||
];
|
||||
IconData _icon(String platform) => switch (platform.toLowerCase()) {
|
||||
'ios' || 'android' => PangolinIcons.smartphone,
|
||||
'macos' || 'windows' || 'linux' => PangolinIcons.laptop,
|
||||
_ => PangolinIcons.monitorSmartphone,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return _SubScaffold(
|
||||
title: widget.t.myDevices,
|
||||
onBack: widget.onBack,
|
||||
embedded: widget.embedded,
|
||||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(widget.t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: [
|
||||
for (var i = 0; i < _devices.length; i++) _row(c, _devices[i], i < _devices.length - 1),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
String _lastSeen(DateTime? ts) {
|
||||
final zh = t.lang == AppLang.zh;
|
||||
if (ts == null) return zh ? '从未在线' : 'never';
|
||||
final d = DateTime.now().difference(ts.toLocal());
|
||||
if (d.inMinutes < 1) return zh ? '刚刚' : 'just now';
|
||||
if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago';
|
||||
if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago';
|
||||
return zh ? '${d.inDays} 天前' : '${d.inDays}d ago';
|
||||
}
|
||||
|
||||
Widget _row(PangolinScheme c, DeviceItem d, bool divider) {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final devicesAsync = ref.watch(devicesProvider);
|
||||
|
||||
Widget content() => devicesAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
const SizedBox(height: 12),
|
||||
if (devices.isEmpty)
|
||||
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
|
||||
style: PangolinText.sm.copyWith(color: c.fg3))
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: [
|
||||
for (var i = 0; i < devices.length; i++) _row(ref, c, devices[i], i < devices.length - 1),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return _SubScaffold(title: t.myDevices, onBack: onBack, embedded: embedded, child: content());
|
||||
}
|
||||
|
||||
Widget _row(WidgetRef ref, PangolinScheme c, Device d, bool divider) {
|
||||
final name = d.name.isNotEmpty ? d.name : d.platform;
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(children: [
|
||||
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
child: Icon(d.icon, size: 19, color: c.accent)),
|
||||
child: Icon(_icon(d.platform), size: 19, color: c.accent)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
|
||||
if (d.me)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5)),
|
||||
),
|
||||
]),
|
||||
Text(name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
|
||||
const SizedBox(height: 1),
|
||||
Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
Text('${d.platform} · ${_lastSeen(d.lastSeen)}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
if (!d.me)
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
|
||||
child: Text(widget.t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => ref.read(devicesProvider.notifier).remove(d.uuid),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
|
||||
child: Text(t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 兑换 & 购买 ──
|
||||
class RedeemScreen extends StatefulWidget {
|
||||
/// ── 兑换 & 购买 ── (兑换码走 POST /v1/redeem,成功后刷新账户)
|
||||
class RedeemScreen extends ConsumerStatefulWidget {
|
||||
const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
||||
final AppText t;
|
||||
final VoidCallback? onBack;
|
||||
final bool embedded;
|
||||
@override
|
||||
State<RedeemScreen> createState() => _RedeemScreenState();
|
||||
ConsumerState<RedeemScreen> createState() => _RedeemScreenState();
|
||||
}
|
||||
|
||||
class _RedeemScreenState extends State<RedeemScreen> {
|
||||
class _RedeemScreenState extends ConsumerState<RedeemScreen> {
|
||||
final _code = TextEditingController();
|
||||
bool _ok = false;
|
||||
bool _busy = false;
|
||||
String? _err;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -177,6 +197,25 @@ class _RedeemScreenState extends State<RedeemScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _redeem() async {
|
||||
final code = _code.text.trim();
|
||||
if (code.isEmpty) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_err = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(accountApiProvider).redeem(code);
|
||||
// 订阅变化 → 刷新账户聚合,套餐横幅/额度随之更新。
|
||||
await ref.read(meProvider.notifier).refresh();
|
||||
if (mounted) setState(() => _ok = true);
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() => _err = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
@@ -218,8 +257,12 @@ class _RedeemScreenState extends State<RedeemScreen> {
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 10),
|
||||
PangolinButton(label: t.redeemBtn, onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
|
||||
PangolinButton(label: t.redeemBtn, onPressed: (_busy || _code.text.trim().length < 4) ? null : _redeem),
|
||||
]),
|
||||
if (_err != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(_err!, style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
|
||||
@@ -65,7 +65,7 @@ class ServerTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('${node.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
SignalBars(ping: node.ping),
|
||||
if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)],
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
@@ -25,13 +25,23 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
// ── 辅助:带 stub 覆盖的 ProviderContainer ──────────────────────
|
||||
// 直接喂固定节点列表(不触发真实探针/HTTP)。
|
||||
class _FakeNodes extends NodesNotifier {
|
||||
_FakeNodes(this.list);
|
||||
final List<Node> list;
|
||||
@override
|
||||
Future<List<Node>> build() async => list;
|
||||
}
|
||||
|
||||
const _nodes = [
|
||||
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 30, uuid: 'hk'),
|
||||
Node(code: 'JP', nameZh: '日本', nameEn: 'Tokyo', ping: 12, uuid: 'jp'),
|
||||
Node(code: 'US', nameZh: '美国', nameEn: 'LA', ping: 200, uuid: 'us'),
|
||||
];
|
||||
|
||||
ProviderContainer makeContainer({List<Override> overrides = const []}) =>
|
||||
ProviderContainer(overrides: [
|
||||
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
|
||||
// effectiveNodeProvider 在 nodesProvider 加载时退回 kDemoNodes,
|
||||
// 所以测试直接读 effectiveNodeProvider,无需等待 nodesProvider.future。
|
||||
...overrides,
|
||||
]);
|
||||
|
||||
@@ -43,21 +53,31 @@ void main() {
|
||||
expect(c.read(isSmartSelectProvider), true);
|
||||
});
|
||||
|
||||
test('智能选择取延迟最小节点(退回 kDemoNodes)', () {
|
||||
test('无节点时 effectiveNode 返回占位(不再伪造演示节点)', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
// nodesProvider 加载中时退回 kDemoNodes,effectiveNodeProvider 立即可用。
|
||||
final node = c.read(effectiveNodeProvider);
|
||||
final minPing = kDemoNodes.map((n) => n.ping).reduce((a, b) => a < b ? a : b);
|
||||
expect(node.ping, minPing);
|
||||
expect(c.read(effectiveNodeProvider).code, kPlaceholderNode.code);
|
||||
});
|
||||
|
||||
test('选中具体节点后生效节点随之改变', () {
|
||||
final c = makeContainer();
|
||||
test('智能选择取实测延迟最小节点', () async {
|
||||
final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]);
|
||||
addTearDown(c.dispose);
|
||||
c.read(selectedNodeCodeProvider.notifier).state = 'JP';
|
||||
await c.read(nodesProvider.future);
|
||||
expect(c.read(effectiveNodeProvider).code, 'JP'); // ping 12 最小
|
||||
});
|
||||
|
||||
test('选中具体节点后生效节点随之改变', () async {
|
||||
final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]);
|
||||
addTearDown(c.dispose);
|
||||
await c.read(nodesProvider.future);
|
||||
c.read(selectedNodeCodeProvider.notifier).state = 'US';
|
||||
expect(c.read(isSmartSelectProvider), false);
|
||||
expect(c.read(effectiveNodeProvider).code, 'JP');
|
||||
expect(c.read(effectiveNodeProvider).code, 'US');
|
||||
});
|
||||
|
||||
test('pingLabel:未测得显示 —', () {
|
||||
expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 0).pingLabel, '—');
|
||||
expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 42).pingLabel, '42ms');
|
||||
});
|
||||
|
||||
test('localizedSub 不串语言:无标签用拉丁地名', () {
|
||||
|
||||