// nodes_page.dart — 节点页(置顶智能选择推荐卡 + 搜索 + 列表/双列网格) import 'dart:async'; 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 createState() => _NodesPageState(); } class _NodesPageState extends ConsumerState { String _q = ''; bool _refreshing = false; Timer? _poll; @override void initState() { super.initState(); // 切到节点页自动刷新一次(受 nodesProvider 的 10s 限流)。 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _refresh(); }); // 在页期间周期刷新,让服务端节点 status(如 down)及时浮现到列表(置灰/不可用)。 _poll = Timer.periodic(const Duration(seconds: 15), (_) { if (mounted) ref.read(nodesProvider.notifier).refresh(); }); } @override void dispose() { _poll?.cancel(); super.dispose(); } Future _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 _confirmSwitch(String code, String name) async { final c = context.pangolin; final t = ref.read(appTextProvider); final ok = await showDialog( 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 _filtered(List 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 []); 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 DServers:padding 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 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)], ], ]), ), ), ), ); } }