feat(client): 节点列表防误触 — 已连接时切换节点先弹确认
未连接(off)点节点直接选→跳连接页(无破坏性,不弹框);已连接/连接中点其他 节点会断开重连,先弹确认框(标题含目标节点名),取消则维持当前连接。新增 nodeSwitchTitle/Body/Confirm 三条文案 + node_connect_confirm_test 覆盖 off/on 两路。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,10 @@ abstract class AppText {
|
||||
String get chooseNode;
|
||||
String get searchPh;
|
||||
String get smartSelect;
|
||||
// 已连接时点其他节点 → 切换确认(防误触)
|
||||
String nodeSwitchTitle(String name);
|
||||
String get nodeSwitchBody;
|
||||
String get nodeSwitchConfirm;
|
||||
String get smartSub;
|
||||
String get recommended;
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@ class StringsEn extends AppText {
|
||||
@override
|
||||
String get smartSelect => 'Smart select';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => 'Switch to $name?';
|
||||
@override
|
||||
String get nodeSwitchBody => 'This disconnects the current link and reconnects to this node.';
|
||||
@override
|
||||
String get nodeSwitchConfirm => 'Switch';
|
||||
@override
|
||||
String get smartSub => 'Picks the best node for your network';
|
||||
@override
|
||||
String get recommended => 'Recommended';
|
||||
|
||||
@@ -63,6 +63,12 @@ class StringsZh extends AppText {
|
||||
@override
|
||||
String get smartSelect => '智能选择';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => '切换到「$name」?';
|
||||
@override
|
||||
String get nodeSwitchBody => '当前连接将断开并重连到该节点。';
|
||||
@override
|
||||
String get nodeSwitchConfirm => '切换';
|
||||
@override
|
||||
String get smartSub => '根据当前网络环境,自动选择最优节点';
|
||||
@override
|
||||
String get recommended => '推荐';
|
||||
|
||||
@@ -49,6 +49,43 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
widget.onPicked();
|
||||
}
|
||||
|
||||
// 点节点:未连接(off)直接选→跳连接页,无破坏性;已连接/连接中切换会断开重连,
|
||||
// 弹确认防误触(误碰节点把当前连接切走)。
|
||||
void _onTapNode(Node n) {
|
||||
final phase = ref.read(connectionProvider).phase;
|
||||
if (phase == VpnPhase.off) {
|
||||
_pick(n.code);
|
||||
return;
|
||||
}
|
||||
_confirmSwitch(n);
|
||||
}
|
||||
|
||||
Future<void> _confirmSwitch(Node n) async {
|
||||
final c = context.pangolin;
|
||||
final t = ref.read(appTextProvider);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
title: Text(t.nodeSwitchTitle(n.localizedName(t.lang)),
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
content: Text(t.nodeSwitchBody, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.nodeSwitchConfirm, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) _pick(n.code);
|
||||
}
|
||||
|
||||
List<Node> _filtered(List<Node> nodes) {
|
||||
if (_q.trim().isEmpty) return nodes;
|
||||
final q = _q.toLowerCase();
|
||||
@@ -95,7 +132,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
childAspectRatio: 4.4,
|
||||
children: [
|
||||
for (final n in nodes)
|
||||
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _pick(n.code)),
|
||||
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _onTapNode(n)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -133,7 +170,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
node: nodes[i],
|
||||
lang: t.lang,
|
||||
active: !smart && nodes[i].code == selected,
|
||||
onTap: () => _pick(nodes[i].code),
|
||||
onTap: () => _onTapNode(nodes[i]),
|
||||
),
|
||||
if (i < nodes.length - 1) Divider(height: 1, thickness: 1, color: c.border),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// node_connect_confirm_test.dart — 节点列表防误触:已连接时点其他节点先弹确认。
|
||||
//
|
||||
// off → 点节点直接选(跳连接页,无破坏性,不弹确认)。
|
||||
// on → 点其他节点弹确认框,取消则不切换(防误碰把当前连接切走)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/node.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
import 'package:pangolin_vpn/screens/nodes_page.dart';
|
||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
// 固定两节点的桩,绕过远端拉取与 10s 限流刷新。
|
||||
class _StubNodes extends NodesNotifier {
|
||||
@override
|
||||
Future<List<Node>> build() async => _nodes;
|
||||
@override
|
||||
Future<void> refresh() async {}
|
||||
}
|
||||
|
||||
const _nodes = [
|
||||
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443),
|
||||
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
|
||||
];
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
const t = StringsZh();
|
||||
|
||||
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
|
||||
nodesProvider.overrideWith(_StubNodes.new),
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
]);
|
||||
|
||||
Future<void> pump(WidgetTester tester, ProviderContainer container) async {
|
||||
await tester.binding.setSurfaceSize(const Size(440, 900));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
home: Scaffold(body: NodesPage(isWide: false, onPicked: () {})),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('未连接:点节点直接选,不弹确认', (tester) async {
|
||||
final container = makeContainer(VpnBridgeMock());
|
||||
addTearDown(container.dispose);
|
||||
await pump(tester, container);
|
||||
|
||||
expect(find.text('香港'), findsOneWidget);
|
||||
await tester.tap(find.text('香港'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.nodeSwitchConfirm), findsNothing, reason: 'off 态不应弹确认');
|
||||
expect(container.read(selectedNodeCodeProvider), 'HK', reason: '直接选中该节点');
|
||||
});
|
||||
|
||||
testWidgets('已连接:点其他节点弹确认 → 取消不切换', (tester) async {
|
||||
final bridge = VpnBridgeMock(connectDelay: const Duration(milliseconds: 10));
|
||||
final container = makeContainer(bridge);
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(container.dispose);
|
||||
await pump(tester, container);
|
||||
|
||||
// 创建控制器(订阅状态流)后驱动到 on;此后只用 pump(固定时长),
|
||||
// 不能 pumpAndSettle(连接计时器每秒触发,永不 settle)。
|
||||
container.read(connectionProvider);
|
||||
await bridge.start('{}');
|
||||
await tester.pump(const Duration(milliseconds: 15));
|
||||
expect(container.read(connectionProvider).phase, VpnPhase.on);
|
||||
|
||||
await tester.tap(find.text('东京'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 350)); // 弹窗入场
|
||||
|
||||
expect(find.text(t.nodeSwitchTitle('东京')), findsOneWidget, reason: 'on 态切换前先弹确认');
|
||||
expect(find.text(t.nodeSwitchConfirm), findsOneWidget);
|
||||
expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '弹窗期间还未切');
|
||||
|
||||
await tester.tap(find.text(t.devCancel));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 350)); // 弹窗退场
|
||||
expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '取消后维持原节点');
|
||||
|
||||
// 收尾:停桥 → 取消 stats 计时器 + 触发 off 回调取消 elapsed 计时器,
|
||||
// 否则 fake-async 在用例结束时报「Pending timers」。
|
||||
await bridge.stop();
|
||||
await tester.pump();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user