Files
pangolin/client/lib/screens/nodes_page.dart
wangjia 617b43083d feat(client/i18n): B1 硬编码文案 6 语化 + B2 语言持久化
B2 持久化:localeProvider 由 StateProvider 改 StateNotifier(LocaleNotifier),
默认英文,选择存 shared_preferences(key pg_lang),重启保留;新增 appTextFor(lang)
helper 供非 Consumer 场景复用。两处切换 .state= 改 .set()。

B1 硬编码 UI 文案 6 语化(AppLangMisc 扩展,exhaustive switch 不会漏):
- 不可用 / 流媒体优化 / 节点未就绪 / 连接失败 / 加载失败 / 暂无设备 / 改邮箱
- 相对时间(刚刚/X分钟前/…)relativeTime(Duration)
- 修 auth_screen 错误消息 bug:原 _errorZh 永远显示中文(连英文用户都中招)→
  按当前语言取 messageZh/En(非中文显英文)

仍回退英文(服务端数据,需服务端多语字段):节点/套餐名(nameZh/nameEn)、
API 错误消息(messageZh/En)。flutter analyze 仅 2 个既有 withOpacity info。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 14:32:13 +08:00

316 lines
11 KiB
Dart
Raw Permalink 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 '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<NodesPage> createState() => _NodesPageState();
}
class _NodesPageState extends ConsumerState<NodesPage> {
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<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.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)],
],
]),
),
),
),
);
}
}