// 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_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 = ''; void _pick(String code) { ref.read(selectedNodeCodeProvider.notifier).state = code; ref.read(connectionProvider.notifier).onNodeChanged(); widget.onPicked(); } 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 ?? kDemoNodes); final smart = selected == kSmartNodeCode; final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode)); final search = _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v)); if (widget.isWide) { // 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。 return ListView( padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), children: [ smartCard, const SizedBox(height: 14), ConstrainedBox(constraints: const BoxConstraints(maxWidth: 380), child: search), 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: () => _pick(n.code)), ], ), ], ); } // 窄屏:顶栏 + 标题 + 推荐卡 + 搜索 + 分组列表 return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ AppTopBar(brand: t.brand), PageTitle(title: t.chooseNode), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ smartCard, const SizedBox(height: 12), search, 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: () => _pick(nodes[i].code), ), 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; return Container( decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), padding: const EdgeInsets.symmetric(horizontal: 14), child: Row(children: [ Icon(PangolinIcons.search, size: 18, color: c.fg3), const SizedBox(width: 10), Expanded( child: TextField( onChanged: onChanged, style: PangolinText.sm.copyWith(color: c.fg1, fontSize: 15), decoration: InputDecoration( isCollapsed: true, contentPadding: const EdgeInsets.symmetric(vertical: 12), border: InputBorder.none, hintText: hint, hintStyle: PangolinText.sm.copyWith(color: c.fg3, fontSize: 15), ), ), ), ]), ); } } /// 宽屏双列网格中的节点块。 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; return Material( color: active ? c.accentSubtle : c.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(PangolinRadius.lg), side: BorderSide(color: active ? c.accentBorder : c.border, width: 1.5), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11), child: Row(children: [ CountryCode(code: node.code, active: active), 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), Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('${node.ping}ms', 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)], ]), ), ), ); } }