feat(client): 记住上次选择的节点 + 自动连接 toast(1.0.24)

- selectedNodeCodeProvider 改为持久化 StateNotifier(select() 落盘、启动恢复,默认智能 AUTO);
  自动连接复用上次选择:智能就智能、具体就具体。
- 自动连接门控加等「选中节点已恢复」(controller.loaded),避免用到未恢复的默认值。
- 自动连接触发时弹 toast「正在自动连接到 X」(全局 ScaffoldMessenger key)。
- 测试:选中节点 select 落盘 + 重启恢复(具体/智能)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 19:31:01 +08:00
parent f2891ff2f9
commit e89f3de748
10 changed files with 113 additions and 7 deletions
+1
View File
@@ -121,6 +121,7 @@ abstract class AppText {
String get autostartSub;
String get autoConnect;
String get autoConnectSub;
String autoConnectingTo(String name); // 自动连接 toast
String get smartRoute;
String get smartRouteSub;
String get killSwitch;
+2
View File
@@ -188,6 +188,8 @@ class StringsEn extends AppText {
@override
String get autoConnectSub => 'Connect automatically on launch';
@override
String autoConnectingTo(String name) => 'Auto-connecting to $name';
@override
String get smartRoute => 'Smart routing';
@override
String get smartRouteSub => 'Accelerate abroad, direct at home';
+2
View File
@@ -187,6 +187,8 @@ class StringsZh extends AppText {
@override
String get autoConnectSub => '启动时自动连接节点';
@override
String autoConnectingTo(String name) => '正在自动连接到 $name';
@override
String get smartRoute => '智能分流';
@override
String get smartRouteSub => '海外应用走加速,国内直连';
+18 -2
View File
@@ -23,6 +23,9 @@ import 'widgets/auth_screen.dart';
import 'widgets/onboarding_screen.dart';
import 'widgets/pangolin_logo.dart';
/// 全局 ScaffoldMessenger key:供无 BuildContext 处(如自动连接)弹 SnackBar/toast。
final rootMessengerKey = GlobalKey<ScaffoldMessengerState>();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)。
@@ -98,6 +101,7 @@ class _PangolinAppState extends ConsumerState<PangolinApp> {
final mode = ref.watch(themeModeProvider);
return MaterialApp(
title: '穿山甲',
scaffoldMessengerKey: rootMessengerKey,
debugShowCheckedModeBanner: false,
theme: PangolinTheme.light,
darkTheme: PangolinTheme.dark,
@@ -131,22 +135,33 @@ class _RootFlowState extends ConsumerState<RootFlow> {
logLine('AutoConnect', 'armed; waiting for settings loaded + nodes ready');
ProviderSubscription<AsyncValue<List<Node>>>? nodeSub;
ProviderSubscription<AppSettings>? settingsSub;
ProviderSubscription<String>? selSub;
var decided = false;
void tryGo() {
if (decided) return;
final settings = ref.read(settingsProvider);
final selLoaded = ref.read(selectedNodeCodeProvider.notifier).loaded;
// 直接判 _connect 实际会用的「生效节点」是否就绪(uuid 非空),而非「列表里有任一就绪节点」——
// 否则可能在 effectiveNode 尚是占位/未就绪时就触发,_connect 报「节点尚未就绪」。
// 还要等设置 + 选中节点都从持久化恢复完,否则可能用到未恢复的默认(智能)而非上次选择。
final node = ref.read(effectiveNodeProvider);
if (!settings.loaded || node.uuid.isEmpty) return;
if (!settings.loaded || !selLoaded || node.uuid.isEmpty) return;
decided = true;
nodeSub?.close();
settingsSub?.close();
selSub?.close();
final phase = ref.read(connectionProvider).phase;
logLine('AutoConnect', 'ready: autoConnect=${settings.autoConnect} phase=$phase node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"}');
if (settings.autoConnect && phase == VpnPhase.off) {
logLine('AutoConnect', 'triggering connect');
logLine('AutoConnect', 'triggering connect${node.code}');
ref.read(connectionProvider.notifier).toggle();
// 自动连接 toast:告知用户「正在自动连接到 X」。
final t = ref.read(appTextProvider);
rootMessengerKey.currentState?.showSnackBar(SnackBar(
content: Text(t.autoConnectingTo(node.localizedName(t.lang))),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
));
} else {
logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)');
}
@@ -154,6 +169,7 @@ class _RootFlowState extends ConsumerState<RootFlow> {
nodeSub = ref.listenManual(nodesProvider, (_, __) => tryGo());
settingsSub = ref.listenManual(settingsProvider, (_, __) => tryGo());
selSub = ref.listenManual(selectedNodeCodeProvider, (_, __) => tryGo());
tryGo();
}
+1 -1
View File
@@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
}
void _pick(String code) {
ref.read(selectedNodeCodeProvider.notifier).state = code;
ref.read(selectedNodeCodeProvider.notifier).select(code);
ref.read(connectionProvider.notifier).onNodeChanged();
widget.onPicked();
}
+1 -1
View File
@@ -262,7 +262,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
final smart = _ref.read(selectedNodeCodeProvider) == kSmartNodeCode;
final altCode = smart ? _pickAlternativeCode(_ref.read(effectiveNodeProvider).code) : null;
if (smart && altCode != null) {
_ref.read(selectedNodeCodeProvider.notifier).state = altCode;
_ref.read(selectedNodeCodeProvider.notifier).select(altCode);
await _disconnect();
await _connect();
// 重连进行中给出「已自动切换」提示(connecting 态显示,连上后随 copyWith 清除)。
+36 -1
View File
@@ -7,6 +7,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/node.dart';
import '../services/api_config.dart';
@@ -96,8 +97,42 @@ final nodesProvider =
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
// ── 当前选中的节点 code'AUTO' 表示智能选择(默认)─────────────────
//
// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。
const _kSelectedNode = 'selected_node_code';
final selectedNodeCodeProvider = StateProvider<String>((ref) => kSmartNodeCode);
class SelectedNodeController extends StateNotifier<String> {
SelectedNodeController() : super(kSmartNodeCode) {
_load();
}
/// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。
bool loaded = false;
Future<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
if (!mounted) return;
final v = p.getString(_kSelectedNode);
if (v != null && v.isNotEmpty) state = v;
} catch (_) {
// 测试/无平台:保留默认(智能)。
}
loaded = true;
}
/// 选择节点(智能传 kSmartNodeCode)并持久化。
Future<void> select(String code) async {
state = code;
try {
final p = await SharedPreferences.getInstance();
await p.setString(_kSelectedNode, code);
} catch (_) {}
}
}
final selectedNodeCodeProvider =
StateNotifierProvider<SelectedNodeController, String>((ref) => SelectedNodeController());
/// 是否处于智能选择。
final isSmartSelectProvider = Provider<bool>(
+1 -1
View File
@@ -1,7 +1,7 @@
name: pangolin_vpn
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
publish_to: "none"
version: 1.0.23+24
version: 1.0.24+25
environment:
sdk: ^3.5.0
@@ -0,0 +1,50 @@
// selected_node_persist_test.dart — 选中节点(智能/具体)跨重启持久化,供自动连接复用上次选择。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<void> waitSelected(ProviderContainer c, String want) async {
for (var i = 0; i < 60; i++) {
if (c.read(selectedNodeCodeProvider) == want) return;
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
test('默认智能选择(AUTO)', () async {
final c = ProviderContainer();
addTearDown(c.dispose);
expect(c.read(selectedNodeCodeProvider), kSmartNodeCode);
});
test('select 具体节点 → 落盘', () async {
final c = ProviderContainer();
addTearDown(c.dispose);
await waitSelected(c, kSmartNodeCode); // 等 _load
await c.read(selectedNodeCodeProvider.notifier).select('JP');
expect(c.read(selectedNodeCodeProvider), 'JP');
final p = await SharedPreferences.getInstance();
expect(p.getString('selected_node_code'), 'JP', reason: '应持久化');
});
test('重启从持久化恢复具体节点', () async {
SharedPreferences.setMockInitialValues({'selected_node_code': 'JP'});
final c = ProviderContainer();
addTearDown(c.dispose);
await waitSelected(c, 'JP');
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '重启应恢复上次选择');
});
test('重启恢复智能选择(AUTO 持久化)', () async {
SharedPreferences.setMockInitialValues({'selected_node_code': kSmartNodeCode});
final c = ProviderContainer();
addTearDown(c.dispose);
await waitSelected(c, kSmartNodeCode);
expect(c.read(selectedNodeCodeProvider), kSmartNodeCode);
});
}
+1 -1
View File
@@ -3,7 +3,7 @@
; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在
; ..\..\build\windows\x64\runner\Release
#define MyAppName "穿山甲 Pangolin"
#define MyAppVersion "1.0.23"
#define MyAppVersion "1.0.24"
#define MyAppPublisher "Pangolin"
#define MyAppExeName "pangolin_vpn.exe"
#define BuildDir "..\..\build\windows\x64\runner\Release"