Files
pangolin/client/lib/screens/nodes_page.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

303 lines
11 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_page.dart — 节点页(置顶智能选择推荐卡 + 搜索 + 列表/双列网格)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/node.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_field.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/server_tile.dart';
import '../widgets/smart_select_card.dart';
class NodesPage extends ConsumerStatefulWidget {
const NodesPage({super.key, required this.isWide, required this.onPicked});
final bool isWide;
final VoidCallback onPicked;
@override
ConsumerState<NodesPage> createState() => _NodesPageState();
}
class _NodesPageState extends ConsumerState<NodesPage> {
String _q = '';
bool _refreshing = false;
@override
void initState() {
super.initState();
// 切到节点页自动刷新一次(受 nodesProvider 的 10s 限流)。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _refresh();
});
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
await ref.read(nodesProvider.notifier).refresh();
if (mounted) setState(() => _refreshing = false);
}
void _pick(String code) {
ref.read(selectedNodeCodeProvider.notifier).select(code);
ref.read(connectionProvider.notifier).onNodeChanged();
widget.onPicked();
}
// 点节点:未连接(off)直接选→跳连接页,无破坏性;已连接/连接中切换会断开重连,
// 弹确认防误触(误碰把当前连接切走)。节点卡与智能选择卡共用此逻辑。
void _onTapNode(Node n) => _onSelect(n.code, n.localizedName(ref.read(appTextProvider).lang));
void _onTapSmart() => _onSelect(kSmartNodeCode, ref.read(appTextProvider).smartSelect);
void _onSelect(String code, String name) {
if (ref.read(connectionProvider).phase == VpnPhase.off) {
_pick(code);
return;
}
_confirmSwitch(code, name);
}
Future<void> _confirmSwitch(String code, String name) 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(name),
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(code);
}
List<Node> _filtered(List<Node> nodes) {
if (_q.trim().isEmpty) return nodes;
final q = _q.toLowerCase();
return nodes
.where((n) => (n.nameZh + n.nameEn + n.code).toLowerCase().contains(q))
.toList();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final selected = ref.watch(selectedNodeCodeProvider);
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? const <Node>[]);
final smart = selected == kSmartNodeCode;
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: _onTapSmart);
// 搜索框 + 刷新按钮(点击刷新);列表本身支持下拉刷新。
final searchRow = Row(children: [
Expanded(child: _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v))),
const SizedBox(width: 10),
_RefreshButton(refreshing: _refreshing, onTap: _refresh),
]);
if (widget.isWide) {
// 对照 dapp.jsx DServerspadding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。
return RefreshIndicator(
color: c.accent,
backgroundColor: c.surface,
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
children: [
smartCard,
const SizedBox(height: 14),
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow),
const SizedBox(height: 14),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 4.4,
children: [
for (final n in nodes)
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _onTapNode(n)),
],
),
],
),
);
}
// 窄屏:顶栏 + 标题 + 推荐卡 + 搜索 + 分组列表
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AppTopBar(brand: t.brand),
PageTitle(title: t.chooseNode),
Expanded(
child: RefreshIndicator(
color: c.accent,
backgroundColor: c.surface,
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
children: [
smartCard,
const SizedBox(height: 12),
searchRow,
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
clipBehavior: Clip.antiAlias,
child: Column(children: [
for (var i = 0; i < nodes.length; i++) ...[
ServerTile(
node: nodes[i],
lang: t.lang,
active: !smart && nodes[i].code == selected,
onTap: () => _onTapNode(nodes[i]),
),
if (i < nodes.length - 1) Divider(height: 1, thickness: 1, color: c.border),
],
]),
),
],
),
),
),
]);
}
}
class _SearchBox extends StatelessWidget {
const _SearchBox({required this.hint, required this.onChanged});
final String hint;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
// 搜索框:只有底色无描边的统一输入框(PangolinFieldBox bordered:false)。
return PangolinFieldBox(
fill: c.bgSubtle,
bordered: false,
leading: PangolinIcons.search,
child: TextField(
onChanged: onChanged,
style: PangolinText.sm.copyWith(color: c.fg1, fontSize: 15),
decoration: bareInputDecoration(
hintText: hint,
hintStyle: PangolinText.sm.copyWith(color: c.fg3, fontSize: 15),
),
),
);
}
}
/// 宽屏双列网格中的节点块。
/// 刷新按钮(搜索框右侧):点击刷新节点列表(受 10s 限流);刷新中显示 spinner。
class _RefreshButton extends StatelessWidget {
const _RefreshButton({required this.refreshing, required this.onTap});
final bool refreshing;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Material(
color: c.bgSubtle,
borderRadius: BorderRadius.circular(PangolinRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: refreshing ? null : onTap,
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: refreshing
? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent))
: Icon(PangolinIcons.refreshCw, size: 18, color: c.fg2),
),
),
),
);
}
}
class _NodeGridTile extends StatelessWidget {
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
final Node node;
final AppLang lang;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
return Opacity(
opacity: down ? 0.55 : 1.0,
child: Material(
color: (active && !down) ? c.accentSubtle : c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
side: BorderSide(color: (active && !down) ? c.accentBorder : c.border, width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: down ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11),
child: Row(children: [
CountryCode(code: node.code, active: active && !down),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(node.localizedName(lang),
overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
Text(node.localizedSub(lang),
overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
]),
),
const SizedBox(width: 8),
if (down)
Text(unavail,
style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w600, fontSize: 12))
else ...[
Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
const SizedBox(height: 5),
SignalBars(ping: node.ping),
]),
if (active) ...[const SizedBox(width: 8), Icon(PangolinIcons.check, size: 18, color: c.accent)],
],
]),
),
),
),
);
}
}