Files
pangolin/client/lib/state/nodes_provider.dart
T
wangjia e89f3de748 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>
2026-06-29 19:31:01 +08:00

157 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
//
// 从 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 'package:shared_preferences/shared_preferences.dart';
import '../models/node.dart';
import '../services/api_config.dart';
import '../services/latency_probe.dart';
import 'auth_provider.dart';
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
class NodesNotifier extends AsyncNotifier<List<Node>> {
bool _disposed = false;
DateTime? _lastFetch;
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
static const _minInterval = Duration(seconds: 10);
@override
Future<List<Node>> build() async {
ref.onDispose(() => _disposed = true);
final auth = ref.watch(authProvider);
if (!auth.isLoggedIn) return const [];
final list = await _fetchNodes(auth.accessToken!);
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
}
/// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换),
/// 避免进页/下拉时列表闪空;失败保留旧数据。
Future<void> refresh() async {
final auth = ref.read(authProvider);
if (!auth.isLoggedIn) {
state = const AsyncData([]);
return;
}
if (_lastFetch != null && DateTime.now().difference(_lastFetch!) < _minInterval) {
return; // 限流:10s 内不重复请求远端
}
final result = await AsyncValue.guard(() async {
final list = await _fetchNodes(auth.accessToken!);
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
});
if (_disposed) return;
if (result.hasValue) state = result; // 成功才换;失败保留旧列表
}
/// 后台实测各节点延迟,完成后回填(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('$kApiBaseUrl/v1/nodes');
final resp = await http.get(uri, headers: {
'Authorization': 'Bearer $accessToken',
}).timeout(const Duration(seconds: 10));
if (resp.statusCode != 200) return const [];
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final rawList = body['nodes'] as List<dynamic>? ?? [];
return rawList.map((e) {
final m = e as Map<String, dynamic>;
return Node(
uuid: m['id'] as String? ?? '',
code: m['region'] as String? ?? '??',
nameZh: m['name_zh'] as String? ?? '',
nameEn: m['name_en'] as String? ?? '',
tier: m['tier'] as String? ?? 'free',
host: m['host'] as String? ?? '',
port: (m['port'] as num?)?.toInt() ?? 0,
status: m['status'] as String? ?? 'up',
ping: 0, // 由 _measure 实测回填
);
}).toList();
}
}
final nodesProvider =
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
// ── 当前选中的节点 code'AUTO' 表示智能选择(默认)─────────────────
//
// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。
const _kSelectedNode = 'selected_node_code';
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>(
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
);
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
final effectiveNodeProvider = Provider<Node>((ref) {
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
if (nodes.isEmpty) return kPlaceholderNode;
final code = ref.watch(selectedNodeCodeProvider);
if (code == kSmartNodeCode) {
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);
});