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 autostartSub;
|
||||||
String get autoConnect;
|
String get autoConnect;
|
||||||
String get autoConnectSub;
|
String get autoConnectSub;
|
||||||
|
String autoConnectingTo(String name); // 自动连接 toast
|
||||||
String get smartRoute;
|
String get smartRoute;
|
||||||
String get smartRouteSub;
|
String get smartRouteSub;
|
||||||
String get killSwitch;
|
String get killSwitch;
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ class StringsEn extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get autoConnectSub => 'Connect automatically on launch';
|
String get autoConnectSub => 'Connect automatically on launch';
|
||||||
@override
|
@override
|
||||||
|
String autoConnectingTo(String name) => 'Auto-connecting to $name…';
|
||||||
|
@override
|
||||||
String get smartRoute => 'Smart routing';
|
String get smartRoute => 'Smart routing';
|
||||||
@override
|
@override
|
||||||
String get smartRouteSub => 'Accelerate abroad, direct at home';
|
String get smartRouteSub => 'Accelerate abroad, direct at home';
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ class StringsZh extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get autoConnectSub => '启动时自动连接节点';
|
String get autoConnectSub => '启动时自动连接节点';
|
||||||
@override
|
@override
|
||||||
|
String autoConnectingTo(String name) => '正在自动连接到 $name…';
|
||||||
|
@override
|
||||||
String get smartRoute => '智能分流';
|
String get smartRoute => '智能分流';
|
||||||
@override
|
@override
|
||||||
String get smartRouteSub => '海外应用走加速,国内直连';
|
String get smartRouteSub => '海外应用走加速,国内直连';
|
||||||
|
|||||||
+18
-2
@@ -23,6 +23,9 @@ import 'widgets/auth_screen.dart';
|
|||||||
import 'widgets/onboarding_screen.dart';
|
import 'widgets/onboarding_screen.dart';
|
||||||
import 'widgets/pangolin_logo.dart';
|
import 'widgets/pangolin_logo.dart';
|
||||||
|
|
||||||
|
/// 全局 ScaffoldMessenger key:供无 BuildContext 处(如自动连接)弹 SnackBar/toast。
|
||||||
|
final rootMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)。
|
// 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)。
|
||||||
@@ -98,6 +101,7 @@ class _PangolinAppState extends ConsumerState<PangolinApp> {
|
|||||||
final mode = ref.watch(themeModeProvider);
|
final mode = ref.watch(themeModeProvider);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '穿山甲',
|
title: '穿山甲',
|
||||||
|
scaffoldMessengerKey: rootMessengerKey,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: PangolinTheme.light,
|
theme: PangolinTheme.light,
|
||||||
darkTheme: PangolinTheme.dark,
|
darkTheme: PangolinTheme.dark,
|
||||||
@@ -131,22 +135,33 @@ class _RootFlowState extends ConsumerState<RootFlow> {
|
|||||||
logLine('AutoConnect', 'armed; waiting for settings loaded + nodes ready');
|
logLine('AutoConnect', 'armed; waiting for settings loaded + nodes ready');
|
||||||
ProviderSubscription<AsyncValue<List<Node>>>? nodeSub;
|
ProviderSubscription<AsyncValue<List<Node>>>? nodeSub;
|
||||||
ProviderSubscription<AppSettings>? settingsSub;
|
ProviderSubscription<AppSettings>? settingsSub;
|
||||||
|
ProviderSubscription<String>? selSub;
|
||||||
var decided = false;
|
var decided = false;
|
||||||
void tryGo() {
|
void tryGo() {
|
||||||
if (decided) return;
|
if (decided) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
final selLoaded = ref.read(selectedNodeCodeProvider.notifier).loaded;
|
||||||
// 直接判 _connect 实际会用的「生效节点」是否就绪(uuid 非空),而非「列表里有任一就绪节点」——
|
// 直接判 _connect 实际会用的「生效节点」是否就绪(uuid 非空),而非「列表里有任一就绪节点」——
|
||||||
// 否则可能在 effectiveNode 尚是占位/未就绪时就触发,_connect 报「节点尚未就绪」。
|
// 否则可能在 effectiveNode 尚是占位/未就绪时就触发,_connect 报「节点尚未就绪」。
|
||||||
|
// 还要等设置 + 选中节点都从持久化恢复完,否则可能用到未恢复的默认(智能)而非上次选择。
|
||||||
final node = ref.read(effectiveNodeProvider);
|
final node = ref.read(effectiveNodeProvider);
|
||||||
if (!settings.loaded || node.uuid.isEmpty) return;
|
if (!settings.loaded || !selLoaded || node.uuid.isEmpty) return;
|
||||||
decided = true;
|
decided = true;
|
||||||
nodeSub?.close();
|
nodeSub?.close();
|
||||||
settingsSub?.close();
|
settingsSub?.close();
|
||||||
|
selSub?.close();
|
||||||
final phase = ref.read(connectionProvider).phase;
|
final phase = ref.read(connectionProvider).phase;
|
||||||
logLine('AutoConnect', 'ready: autoConnect=${settings.autoConnect} phase=$phase node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"}');
|
logLine('AutoConnect', 'ready: autoConnect=${settings.autoConnect} phase=$phase node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"}');
|
||||||
if (settings.autoConnect && phase == VpnPhase.off) {
|
if (settings.autoConnect && phase == VpnPhase.off) {
|
||||||
logLine('AutoConnect', 'triggering connect');
|
logLine('AutoConnect', 'triggering connect → ${node.code}');
|
||||||
ref.read(connectionProvider.notifier).toggle();
|
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 {
|
} else {
|
||||||
logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)');
|
logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)');
|
||||||
}
|
}
|
||||||
@@ -154,6 +169,7 @@ class _RootFlowState extends ConsumerState<RootFlow> {
|
|||||||
|
|
||||||
nodeSub = ref.listenManual(nodesProvider, (_, __) => tryGo());
|
nodeSub = ref.listenManual(nodesProvider, (_, __) => tryGo());
|
||||||
settingsSub = ref.listenManual(settingsProvider, (_, __) => tryGo());
|
settingsSub = ref.listenManual(settingsProvider, (_, __) => tryGo());
|
||||||
|
selSub = ref.listenManual(selectedNodeCodeProvider, (_, __) => tryGo());
|
||||||
tryGo();
|
tryGo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _pick(String code) {
|
void _pick(String code) {
|
||||||
ref.read(selectedNodeCodeProvider.notifier).state = code;
|
ref.read(selectedNodeCodeProvider.notifier).select(code);
|
||||||
ref.read(connectionProvider.notifier).onNodeChanged();
|
ref.read(connectionProvider.notifier).onNodeChanged();
|
||||||
widget.onPicked();
|
widget.onPicked();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
final smart = _ref.read(selectedNodeCodeProvider) == kSmartNodeCode;
|
final smart = _ref.read(selectedNodeCodeProvider) == kSmartNodeCode;
|
||||||
final altCode = smart ? _pickAlternativeCode(_ref.read(effectiveNodeProvider).code) : null;
|
final altCode = smart ? _pickAlternativeCode(_ref.read(effectiveNodeProvider).code) : null;
|
||||||
if (smart && altCode != null) {
|
if (smart && altCode != null) {
|
||||||
_ref.read(selectedNodeCodeProvider.notifier).state = altCode;
|
_ref.read(selectedNodeCodeProvider.notifier).select(altCode);
|
||||||
await _disconnect();
|
await _disconnect();
|
||||||
await _connect();
|
await _connect();
|
||||||
// 重连进行中给出「已自动切换」提示(connecting 态显示,连上后随 copyWith 清除)。
|
// 重连进行中给出「已自动切换」提示(connecting 态显示,连上后随 copyWith 清除)。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/node.dart';
|
import '../models/node.dart';
|
||||||
import '../services/api_config.dart';
|
import '../services/api_config.dart';
|
||||||
@@ -96,8 +97,42 @@ final nodesProvider =
|
|||||||
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
|
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
|
||||||
|
|
||||||
// ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)─────────────────
|
// ── 当前选中的节点 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>(
|
final isSmartSelectProvider = Provider<bool>(
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pangolin_vpn
|
name: pangolin_vpn
|
||||||
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
|
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.0.23+24
|
version: 1.0.24+25
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.0
|
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),产物在
|
; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在
|
||||||
; ..\..\build\windows\x64\runner\Release
|
; ..\..\build\windows\x64\runner\Release
|
||||||
#define MyAppName "穿山甲 Pangolin"
|
#define MyAppName "穿山甲 Pangolin"
|
||||||
#define MyAppVersion "1.0.23"
|
#define MyAppVersion "1.0.24"
|
||||||
#define MyAppPublisher "Pangolin"
|
#define MyAppPublisher "Pangolin"
|
||||||
#define MyAppExeName "pangolin_vpn.exe"
|
#define MyAppExeName "pangolin_vpn.exe"
|
||||||
#define BuildDir "..\..\build\windows\x64\runner\Release"
|
#define BuildDir "..\..\build\windows\x64\runner\Release"
|
||||||
|
|||||||
Reference in New Issue
Block a user