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
This commit is contained in:
@@ -34,6 +34,174 @@ extension AppLangLabel on AppLang {
|
||||
}
|
||||
}
|
||||
|
||||
/// 少量「拿不到 AppText 实例的场景」(model / 非 Consumer 的 tile,只有 AppLang)
|
||||
/// 用到的零散文案。exhaustive switch 保证 6 语全覆盖,不会漏译回退英文。
|
||||
/// 仍归属 l10n 层(scan-redline 覆盖本文件),不违反「文案集中」铁律。
|
||||
extension AppLangMisc on AppLang {
|
||||
/// 节点不可用(agent 离线)。
|
||||
String get unavailable {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '不可用';
|
||||
case AppLang.en:
|
||||
return 'Unavailable';
|
||||
case AppLang.ja:
|
||||
return '利用不可';
|
||||
case AppLang.ko:
|
||||
return '사용 불가';
|
||||
case AppLang.ru:
|
||||
return 'Недоступно';
|
||||
case AppLang.es:
|
||||
return 'No disponible';
|
||||
}
|
||||
}
|
||||
|
||||
/// 节点标签:流媒体优化。
|
||||
String get tagStreaming {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '流媒体优化';
|
||||
case AppLang.en:
|
||||
return 'Streaming';
|
||||
case AppLang.ja:
|
||||
return 'ストリーミング最適化';
|
||||
case AppLang.ko:
|
||||
return '스트리밍 최적화';
|
||||
case AppLang.ru:
|
||||
return 'Оптимизация стриминга';
|
||||
case AppLang.es:
|
||||
return 'Optimización de streaming';
|
||||
}
|
||||
}
|
||||
|
||||
/// 节点尚未就绪(列表加载中/为空)。
|
||||
String get nodesNotReady {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '节点尚未就绪,请稍候重试';
|
||||
case AppLang.en:
|
||||
return 'Nodes not ready, please retry';
|
||||
case AppLang.ja:
|
||||
return 'ノードの準備ができていません。しばらくして再試行してください';
|
||||
case AppLang.ko:
|
||||
return '노드가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Узлы не готовы, повторите попытку';
|
||||
case AppLang.es:
|
||||
return 'Nodos no listos, inténtalo de nuevo';
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接失败(通用兜底)。
|
||||
String get connectFailed {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '连接失败,请重试';
|
||||
case AppLang.en:
|
||||
return 'Connection failed, please retry';
|
||||
case AppLang.ja:
|
||||
return '接続に失敗しました。再試行してください';
|
||||
case AppLang.ko:
|
||||
return '연결에 실패했습니다. 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Не удалось подключиться, повторите попытку';
|
||||
case AppLang.es:
|
||||
return 'Error de conexión, inténtalo de nuevo';
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据加载失败。
|
||||
String get loadFailedRetry {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '加载失败,请重试';
|
||||
case AppLang.en:
|
||||
return 'Failed to load, retry';
|
||||
case AppLang.ja:
|
||||
return '読み込みに失敗しました。再試行してください';
|
||||
case AppLang.ko:
|
||||
return '불러오기에 실패했습니다. 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Не удалось загрузить, повторите';
|
||||
case AppLang.es:
|
||||
return 'Error al cargar, reintentar';
|
||||
}
|
||||
}
|
||||
|
||||
/// 无已登录设备。
|
||||
String get noDevices {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '暂无已登录设备';
|
||||
case AppLang.en:
|
||||
return 'No devices yet';
|
||||
case AppLang.ja:
|
||||
return 'ログイン済みのデバイスはありません';
|
||||
case AppLang.ko:
|
||||
return '로그인된 기기가 없습니다';
|
||||
case AppLang.ru:
|
||||
return 'Пока нет устройств';
|
||||
case AppLang.es:
|
||||
return 'Aún no hay dispositivos';
|
||||
}
|
||||
}
|
||||
|
||||
/// 「改邮箱」按钮短标签。
|
||||
String get changeEmail {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '改邮箱';
|
||||
case AppLang.en:
|
||||
return 'Change';
|
||||
case AppLang.ja:
|
||||
return '変更';
|
||||
case AppLang.ko:
|
||||
return '변경';
|
||||
case AppLang.ru:
|
||||
return 'Изменить';
|
||||
case AppLang.es:
|
||||
return 'Cambiar';
|
||||
}
|
||||
}
|
||||
|
||||
/// 相对时间(设备最后在线):刚刚 / X 分钟前 / X 小时前 / X 天前。
|
||||
String relativeTime(Duration d) {
|
||||
final m = d.inMinutes, h = d.inHours, days = d.inDays;
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
if (m < 1) return '刚刚';
|
||||
if (m < 60) return '$m 分钟前';
|
||||
if (h < 24) return '$h 小时前';
|
||||
return '$days 天前';
|
||||
case AppLang.en:
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return '$m min ago';
|
||||
if (h < 24) return '${h}h ago';
|
||||
return '${days}d ago';
|
||||
case AppLang.ja:
|
||||
if (m < 1) return 'たった今';
|
||||
if (m < 60) return '$m 分前';
|
||||
if (h < 24) return '$h 時間前';
|
||||
return '$days 日前';
|
||||
case AppLang.ko:
|
||||
if (m < 1) return '방금';
|
||||
if (m < 60) return '$m분 전';
|
||||
if (h < 24) return '$h시간 전';
|
||||
return '$days일 전';
|
||||
case AppLang.ru:
|
||||
if (m < 1) return 'только что';
|
||||
if (m < 60) return '$m мин назад';
|
||||
if (h < 24) return '$h ч назад';
|
||||
return '$days дн назад';
|
||||
case AppLang.es:
|
||||
if (m < 1) return 'ahora mismo';
|
||||
if (m < 60) return 'hace $m min';
|
||||
if (h < 24) return 'hace $h h';
|
||||
return 'hace $days d';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
||||
abstract class AppText {
|
||||
const AppText();
|
||||
|
||||
@@ -66,7 +66,7 @@ class Node {
|
||||
String localizedSub(AppLang lang) {
|
||||
switch (tag) {
|
||||
case NodeTag.streaming:
|
||||
return lang == AppLang.zh ? '流媒体优化' : 'Streaming';
|
||||
return lang.tagStreaming;
|
||||
case NodeTag.p2p:
|
||||
return 'P2P';
|
||||
case NodeTag.none:
|
||||
|
||||
@@ -75,7 +75,7 @@ class AccountPage extends ConsumerWidget {
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.globe,
|
||||
title: t.language,
|
||||
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l),
|
||||
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)),
|
||||
),
|
||||
Divider(height: 1, color: c.border),
|
||||
_CustomRow(
|
||||
|
||||
@@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
||||
final unavail = lang.unavailable;
|
||||
return Opacity(
|
||||
opacity: down ? 0.55 : 1.0,
|
||||
child: Material(
|
||||
|
||||
@@ -72,7 +72,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
// 配置组
|
||||
_Card(children: [
|
||||
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)),
|
||||
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))),
|
||||
_Row(
|
||||
title: t.darkAppearance,
|
||||
sub: isDark ? t.stateOn : t.followLight,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../l10n/strings_en.dart';
|
||||
@@ -12,12 +13,10 @@ import '../l10n/strings_zh.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 当前语言(单显)。设置/账户页切换。默认英文(国际化默认语种)。
|
||||
final localeProvider = StateProvider<AppLang>((ref) => AppLang.en);
|
||||
|
||||
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
|
||||
final appTextProvider = Provider<AppText>((ref) {
|
||||
switch (ref.watch(localeProvider)) {
|
||||
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
|
||||
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
|
||||
AppText appTextFor(AppLang lang) {
|
||||
switch (lang) {
|
||||
case AppLang.zh:
|
||||
return const StringsZh();
|
||||
case AppLang.en:
|
||||
@@ -31,7 +30,48 @@ final appTextProvider = Provider<AppText>((ref) {
|
||||
case AppLang.es:
|
||||
return const StringsEs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
|
||||
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
|
||||
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。
|
||||
class LocaleNotifier extends StateNotifier<AppLang> {
|
||||
LocaleNotifier() : super(AppLang.en) {
|
||||
_load();
|
||||
}
|
||||
static const _key = 'pg_lang';
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getString(_key);
|
||||
if (saved != null) {
|
||||
for (final l in AppLang.values) {
|
||||
if (l.name == saved) {
|
||||
state = l;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* 读失败保持默认 en */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set(AppLang lang) async {
|
||||
state = lang;
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setString(_key, lang.name);
|
||||
} catch (_) {
|
||||
/* 持久化失败忽略,本次会话仍生效 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final localeProvider =
|
||||
StateNotifierProvider<LocaleNotifier, AppLang>((ref) => LocaleNotifier());
|
||||
|
||||
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
|
||||
final appTextProvider = Provider<AppText>((ref) => appTextFor(ref.watch(localeProvider)));
|
||||
|
||||
/// 主题模式。默认跟随系统;设置页可显式切深色。
|
||||
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||
|
||||
@@ -203,7 +203,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||
|
||||
final node = _ref.read(effectiveNodeProvider);
|
||||
final zh = _ref.read(localeProvider) == AppLang.zh;
|
||||
final lang = _ref.read(localeProvider);
|
||||
final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方)
|
||||
logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} '
|
||||
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
|
||||
|
||||
@@ -212,7 +213,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
|
||||
error: lang.nodesNotReady,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -244,7 +245,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
|
||||
error: lang.connectFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class PlansScreen extends ConsumerWidget {
|
||||
Widget body() => plansAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (plans) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||
children: [
|
||||
@@ -140,13 +140,8 @@ class DevicesScreen extends ConsumerWidget {
|
||||
|
||||
// 相对时间(最后登录);null → 从未登录。
|
||||
String _rel(DateTime? ts) {
|
||||
final zh = t.lang == AppLang.zh;
|
||||
if (ts == null) return t.devNeverLogin;
|
||||
final d = DateTime.now().difference(ts.toLocal());
|
||||
if (d.inMinutes < 1) return zh ? '刚刚' : 'just now';
|
||||
if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago';
|
||||
if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago';
|
||||
return zh ? '${d.inDays} 天前' : '${d.inDays}d ago';
|
||||
return t.lang.relativeTime(DateTime.now().difference(ts.toLocal()));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -158,12 +153,12 @@ class DevicesScreen extends ConsumerWidget {
|
||||
Widget content() => devicesAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
const SizedBox(height: 12),
|
||||
if (devices.isEmpty)
|
||||
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
|
||||
Text(t.lang.noDevices,
|
||||
style: PangolinText.sm.copyWith(color: c.fg3))
|
||||
else
|
||||
Container(
|
||||
|
||||
@@ -39,7 +39,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
bool _sent = false;
|
||||
bool _loading = false;
|
||||
bool _pwVisible = false;
|
||||
String? _errorZh;
|
||||
String? _error;
|
||||
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
@@ -90,17 +90,17 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
// ── 认证操作(逻辑不变)──────────────────────────────────────────
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await _api.sendCode(_email.text.trim());
|
||||
if (mounted) setState(() { _sent = true; _loading = false; });
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doRegister() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||
final tokens = await _api.register(
|
||||
@@ -113,12 +113,12 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doLogin() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||
final tokens = await _api.login(
|
||||
@@ -132,7 +132,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
_mode = m;
|
||||
_step = 0;
|
||||
_sent = false;
|
||||
_errorZh = null;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────
|
||||
@@ -278,7 +278,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
child: Column(children: [
|
||||
_segTabs(c, t),
|
||||
const SizedBox(height: 22),
|
||||
if (_errorZh != null) ...[
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -289,7 +289,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
Expanded(child: Text(_error!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
@@ -457,7 +457,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _step = 0),
|
||||
child: Text(t.lang == AppLang.zh ? '改邮箱' : 'Change',
|
||||
child: Text(t.lang.changeEmail,
|
||||
style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -44,7 +44,7 @@ class ServerTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
||||
final unavail = lang.unavailable;
|
||||
return InkWell(
|
||||
onTap: down ? null : onTap,
|
||||
child: Opacity(
|
||||
|
||||
Reference in New Issue
Block a user