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:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 清除)。
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user