Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+236
View File
@@ -0,0 +1,236 @@
// app_updater.dart — App 内下载并安装更新(不再开浏览器)。
//
// 对照 jiu core/update/app_updater_io.dart,但补上了 jiu 没有的 Android 原生安装:
// - Android:http 流式下 APK 到临时目录 → open_filex 拉起系统安装器
// (open_filex 内部封装 FileProvider;manifest 需 REQUEST_INSTALL_PACKAGES)
// - Windows:下 exe → Process.start(detached) 起安装 → exit(0) 让其覆盖
// - macOS :下 zip → open 解压 + 访达显示,提示手动拖入「应用程序」
// (本 app 带系统扩展,不自动替换 bundle,避免破坏 sysext)
// - iOS :不能 App 内装 → 外链(TestFlight/App Store)
// 进度用本地 ValueNotifier 驱动一个不可关闭的进度对话框;失败降级浏览器下载。
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../l10n/app_text.dart';
import '../../pangolin_theme.dart';
import '../../state/update_provider.dart';
import '../../widgets/pangolin_icons.dart';
/// 更新对话框「下载更新」入口:按平台 App 内下载并安装;iOS/无直链降级浏览器。
Future<void> startInAppUpdate(BuildContext context, AppText t, AppUpdateInfo info) async {
final url = platformDownloadUrl(info.downloadUrls);
if (url == null || url.isEmpty) return;
// iOS 不允许 App 内安装 → 外链(download_urls['ios'] 应为 TestFlight/App Store)。
if (Platform.isIOS) {
await _openInBrowser(url);
return;
}
final progress = ValueNotifier<double>(0);
final installing = ValueNotifier<bool>(false);
var dialogOpen = true;
void closeDialog() {
if (context.mounted && dialogOpen) {
dialogOpen = false;
Navigator.of(context, rootNavigator: true).pop();
}
}
// 不可关闭的进度框。
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: _ProgressDialog(t: t, progress: progress, installing: installing),
),
));
try {
final savePath = await _savePath(url);
await _download(url, savePath, (p) => progress.value = p);
installing.value = true;
await _install(savePath); // Win/macOS 可能在此 exit(0),不再返回
closeDialog();
if (Platform.isMacOS && context.mounted) {
await _showInfo(context, t, t.lang.updateMacReveal);
}
} catch (_) {
closeDialog();
if (context.mounted) await _showFailed(context, t, url);
} finally {
progress.dispose();
installing.dispose();
}
}
/// 各平台下载文件的落盘路径。
Future<String> _savePath(String url) async {
final tmp = await getTemporaryDirectory();
if (Platform.isWindows) return '${tmp.path}${Platform.pathSeparator}pangolin-update-setup.exe';
if (Platform.isMacOS) {
// 下到「下载」目录便于用户在访达里操作;取不到则回退临时目录。
final dl = await getDownloadsDirectory();
final dir = dl ?? tmp;
return '${dir.path}/pangolin-update.zip';
}
return '${tmp.path}/pangolin-update.apk'; // Android
}
/// http 流式下载 + 进度回调。失败抛异常(由调用方降级)。
Future<void> _download(String url, String savePath, void Function(double) onProgress) async {
final client = http.Client();
try {
final req = http.Request('GET', Uri.parse(url));
final resp = await client.send(req);
if (resp.statusCode != 200) {
throw HttpException('HTTP ${resp.statusCode}', uri: Uri.parse(url));
}
final total = resp.contentLength ?? 0;
final file = File(savePath);
final sink = file.openWrite();
var received = 0;
try {
await for (final chunk in resp.stream) {
received += chunk.length;
sink.add(chunk);
if (total > 0) onProgress(received / total);
}
await sink.flush();
} finally {
await sink.close();
}
} finally {
client.close();
}
}
/// 下载后触发安装/打开。
Future<void> _install(String path) async {
if (Platform.isAndroid) {
await OpenFilex.open(path); // 系统安装器(open_filex 内封 FileProvider)
return;
}
if (Platform.isWindows) {
await Process.start(path, const [], mode: ProcessStartMode.detached);
await Future<void>.delayed(const Duration(milliseconds: 400));
exit(0); // 退出让安装器覆盖
}
if (Platform.isMacOS) {
// 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。
await Process.run('open', <String>[path]); // Archive Utility 解压
await Process.run('open', <String>['-R', path]); // 访达定位
}
}
Future<void> _openInBrowser(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
Future<void> _showInfo(BuildContext context, AppText t, String msg) {
final c = context.pangolin;
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
content: Text(msg, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('OK', style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
),
);
}
Future<void> _showFailed(BuildContext context, AppText t, String url) {
final c = context.pangolin;
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
content: Text(t.lang.updateDownloadFailed, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(t.lang.updateCancelBtn, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await _openInBrowser(url);
},
child: Text(t.lang.updateOpenBrowser, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
),
);
}
class _ProgressDialog extends StatelessWidget {
const _ProgressDialog({required this.t, required this.progress, required this.installing});
final AppText t;
final ValueNotifier<double> progress;
final ValueNotifier<bool> installing;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
title: Row(children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(t.updateDownload,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
),
]),
content: ValueListenableBuilder<bool>(
valueListenable: installing,
builder: (_, inst, __) => ValueListenableBuilder<double>(
valueListenable: progress,
builder: (_, p, __) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(PangolinRadius.full),
child: LinearProgressIndicator(
value: inst || p <= 0 ? null : p,
minHeight: 6,
backgroundColor: c.bgSubtle,
valueColor: AlwaysStoppedAnimation<Color>(c.accent),
),
),
const SizedBox(height: 12),
Text(
inst ? t.lang.updateInstalling : t.lang.updateDownloadingPercent((p * 100).clamp(0, 100).round()),
style: PangolinText.sm.copyWith(color: c.fg2),
),
],
),
),
),
);
}
}
+337 -1
View File
@@ -11,7 +11,322 @@
// 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。
/// 受支持语言。单显——任一时刻只渲染其一。
enum AppLang { zh, en }
/// zh 中文 · en English · ja 日本語 · ko 한국어 · ru Русский · es Español。
enum AppLang { zh, en, ja, ko, ru, es }
/// 语言的本地名(用于语言切换 UI,各语言用自身文字标注)。
extension AppLangLabel on AppLang {
String get nativeLabel {
switch (this) {
case AppLang.zh:
return '中文';
case AppLang.en:
return 'English';
case AppLang.ja:
return '日本語';
case AppLang.ko:
return '한국어';
case AppLang.ru:
return 'Русский';
case AppLang.es:
return 'Español';
}
}
}
/// 少量「拿不到 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';
}
}
/// 更新下载进度:「正在下载 X%」。
String updateDownloadingPercent(int pct) {
switch (this) {
case AppLang.zh:
return '正在下载 $pct%';
case AppLang.en:
return 'Downloading $pct%';
case AppLang.ja:
return 'ダウンロード中 $pct%';
case AppLang.ko:
return '다운로드 중 $pct%';
case AppLang.ru:
return 'Загрузка $pct%';
case AppLang.es:
return 'Descargando $pct%';
}
}
/// 下载完成、正在安装。
String get updateInstalling {
switch (this) {
case AppLang.zh:
return '下载完成,正在安装…';
case AppLang.en:
return 'Downloaded, installing…';
case AppLang.ja:
return 'ダウンロード完了、インストール中…';
case AppLang.ko:
return '다운로드 완료, 설치 중…';
case AppLang.ru:
return 'Загружено, установка…';
case AppLang.es:
return 'Descargado, instalando…';
}
}
/// 下载失败。
String get updateDownloadFailed {
switch (this) {
case AppLang.zh:
return '下载失败,请重试或改用浏览器下载。';
case AppLang.en:
return 'Download failed. Retry or download in your browser.';
case AppLang.ja:
return 'ダウンロードに失敗しました。再試行するかブラウザでダウンロードしてください。';
case AppLang.ko:
return '다운로드에 실패했습니다. 다시 시도하거나 브라우저로 다운로드하세요.';
case AppLang.ru:
return 'Не удалось загрузить. Повторите или скачайте в браузере.';
case AppLang.es:
return 'Error de descarga. Reintenta o descarga en el navegador.';
}
}
/// macOS:已下载,提示手动拖入应用程序。
String get updateMacReveal {
switch (this) {
case AppLang.zh:
return '已下载并解压。请在访达中将 Pangolin 拖入「应用程序」文件夹替换旧版,然后重新打开。';
case AppLang.en:
return 'Downloaded and extracted. In Finder, drag Pangolin into your Applications folder to replace the old version, then reopen.';
case AppLang.ja:
return 'ダウンロードと展開が完了しました。Finder で Pangolin を「アプリケーション」フォルダにドラッグして置き換え、再度開いてください。';
case AppLang.ko:
return '다운로드 및 압축 해제 완료. Finder에서 Pangolin을 「응용 프로그램」 폴더로 드래그해 이전 버전을 교체한 뒤 다시 여세요.';
case AppLang.ru:
return 'Загружено и распаковано. В Finder перетащите Pangolin в папку «Программы», заменив старую версию, затем откройте заново.';
case AppLang.es:
return 'Descargado y extraído. En Finder, arrastra Pangolin a tu carpeta de Aplicaciones para reemplazar la versión anterior y vuelve a abrir.';
}
}
/// 失败对话框:改用浏览器下载。
String get updateOpenBrowser {
switch (this) {
case AppLang.zh:
return '浏览器下载';
case AppLang.en:
return 'Open in browser';
case AppLang.ja:
return 'ブラウザで開く';
case AppLang.ko:
return '브라우저에서 열기';
case AppLang.ru:
return 'Открыть в браузере';
case AppLang.es:
return 'Abrir en el navegador';
}
}
/// 通用「取消」。
String get updateCancelBtn {
switch (this) {
case AppLang.zh:
return '取消';
case AppLang.en:
return 'Cancel';
case AppLang.ja:
return 'キャンセル';
case AppLang.ko:
return '취소';
case AppLang.ru:
return 'Отмена';
case AppLang.es:
return 'Cancelar';
}
}
/// 更新 banner:「立即更新」。
String get updateNow {
switch (this) {
case AppLang.zh:
return '立即更新';
case AppLang.en:
return 'Update now';
case AppLang.ja:
return '今すぐ更新';
case AppLang.ko:
return '지금 업데이트';
case AppLang.ru:
return 'Обновить';
case AppLang.es:
return 'Actualizar';
}
}
}
/// 全部界面文案的抽象契约。zh / en 各实现一份。
abstract class AppText {
@@ -45,8 +360,20 @@ abstract class AppText {
String get quotaToday;
String get minutes;
String get quotaFree;
String get planFreeTag; // 额度卡右上短标签「免费版」(不含「每日10分钟」,避免挤占行宽)
String get watchAd;
String get adUnlocked;
// 免费版 10 分钟卡控 + 看广告加时(累加式)
String get quotaLeftLabel; // 连接期倒计时前缀「剩余」
String get quotaUsedUp; // 额度卡:今日已用完
String get watchAdMore; // 「看广告加时」CTA
String get quotaExhaustedNotice; // 倒计时归零自动切断提示
String get adPlaying; // 占位广告:播放中
String adRewarded(int minutes); // 占位广告:已加 N 分钟
String get adFailed; // 看广告加时失败
String get quotaDesktopTitle; // 桌面版额度耗尽弹窗标题
String get quotaDesktopBody; // 桌面版额度耗尽弹窗正文
String get gotIt; // 知道了
// ── 节点页 ──
String get chooseNode;
@@ -59,6 +386,7 @@ abstract class AppText {
// 连通看门狗:当前节点数据面不可用时的提示
String get nodeUnhealthySwitched; // 智能选择已自动切换
String get nodeUnhealthyError; // 手动节点:断开并提示
String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18)
String get smartSub;
String get recommended;
@@ -135,6 +463,14 @@ abstract class AppText {
String get killSwitch;
String get killSwitchSub;
String get checkUpdate;
String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登)
// 更新检查(设置页「检查更新」手动触发)
String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」
String get updateNotesFallback; // 无 release_notes 时的兜底文案
String get updateLater;
String get updateDownload;
String get updateUpToDate; // 检查后:已是最新版本
String get updateCheckFailed; // 检查失败(网络异常)
// ── 套餐选择 ──
String get choosePlan;
+38
View File
@@ -53,9 +53,31 @@ class StringsEn extends AppText {
@override
String get quotaFree => 'Free · 10 min/day';
@override
String get planFreeTag => 'Free';
@override
String get watchAd => 'Watch ad to start';
@override
String get adUnlocked => 'Unlocked for today';
@override
String get quotaLeftLabel => 'Left';
@override
String get quotaUsedUp => 'Daily free time used up';
@override
String get watchAdMore => 'Watch ad for more time';
@override
String get quotaExhaustedNotice => 'Daily free time used up. Watch an ad to add time or upgrade.';
@override
String get adPlaying => 'Ad playing…';
@override
String adRewarded(int minutes) => '+$minutes min added';
@override
String get adFailed => 'Failed to add time, please retry';
@override
String get quotaDesktopTitle => 'Daily free time used up';
@override
String get quotaDesktopBody => 'Watch ads in the mobile app to add time, or upgrade for unlimited access.';
@override
String get gotIt => 'Got it';
@override
String get chooseNode => 'Choose server';
@@ -74,6 +96,8 @@ class StringsEn extends AppText {
@override
String get nodeUnhealthyError => 'Current node is unreachable — reconnect or switch node';
@override
String get nodeReconnecting => 'Network unstable, reconnecting…';
@override
String get smartSub => 'Picks the best node for your network';
@override
String get recommended => 'Recommended';
@@ -211,6 +235,20 @@ class StringsEn extends AppText {
String get killSwitchSub => 'Block traffic if the link drops';
@override
String get checkUpdate => 'Check for updates';
@override
String get webUserCenter => 'User Center (Web)';
@override
String updateAvailableTitle(String version) => 'New version v$version available';
@override
String get updateNotesFallback => 'This update includes fixes and stability improvements.';
@override
String get updateLater => 'Later';
@override
String get updateDownload => 'Download update';
@override
String get updateUpToDate => 'You are up to date';
@override
String get updateCheckFailed => 'Update check failed, try again later';
@override
String get choosePlan => 'Choose plan';
+373
View File
@@ -0,0 +1,373 @@
// strings_es.dart — Recursos de texto en español (visualización monolingüe)
//
// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere.
// Posicionamiento: aceleración de red / optimización de la experiencia + privacidad.
// La numeración de planes sigue design/CLAUDE.md §7.
import 'app_text.dart';
class StringsEs extends AppText {
const StringsEs();
@override
AppLang get lang => AppLang.es;
@override
String get brand => 'Pangolin';
@override
String get online => '● En línea';
@override
String get offline => '○ Desconectado';
@override
String get capOff => 'Toca para conectar';
@override
String get capConnecting => 'Conectando…';
@override
String get capOn => 'Conectado · Cifrado';
@override
String get connectNow => 'CONECTAR';
@override
String get secure => 'SEGURO';
@override
String get tabConnect => 'Conectar';
@override
String get tabServers => 'Servidores';
@override
String get tabStats => 'Estadísticas';
@override
String get tabMe => 'Cuenta';
@override
String get currentNode => 'Nodo actual';
@override
String get download => 'Bajada';
@override
String get upload => 'Subida';
@override
String get latency => 'Ping';
@override
String get quotaToday => 'Restante hoy';
@override
String get minutes => 'min';
@override
String get quotaFree => 'Gratis · 10 min/día';
@override
String get planFreeTag => 'Gratis';
@override
String get watchAd => 'Mira un anuncio para empezar';
@override
String get adUnlocked => 'Desbloqueado por hoy';
@override
String get quotaLeftLabel => 'Restante';
@override
String get quotaUsedUp => 'Tiempo gratis diario agotado';
@override
String get watchAdMore => 'Mira un anuncio para más tiempo';
@override
String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.';
@override
String get adPlaying => 'Reproduciendo anuncio…';
@override
String adRewarded(int minutes) => '+$minutes min añadidos';
@override
String get adFailed => 'No se pudo añadir tiempo, inténtalo de nuevo';
@override
String get quotaDesktopTitle => 'Tiempo gratis diario agotado';
@override
String get quotaDesktopBody => 'Mira anuncios en la app móvil para añadir tiempo, o mejora tu plan para acceso ilimitado.';
@override
String get gotIt => 'Entendido';
@override
String get chooseNode => 'Elegir servidor';
@override
String get searchPh => 'Buscar país / ciudad';
@override
String get smartSelect => 'Selección inteligente';
@override
String nodeSwitchTitle(String name) => '¿Cambiar a $name?';
@override
String get nodeSwitchBody => 'Esto desconecta el enlace actual y reconecta a este nodo.';
@override
String get nodeSwitchConfirm => 'Cambiar';
@override
String get nodeUnhealthySwitched => 'Nodo actual inaccesible: cambiado automáticamente';
@override
String get nodeUnhealthyError => 'El nodo actual está inaccesible: reconecta o cambia de nodo';
@override
String get nodeReconnecting => 'Red inestable, reconectando…';
@override
String get smartSub => 'Elige el mejor nodo para tu red';
@override
String get recommended => 'Recomendado';
@override
String get statsTitle => 'Estadísticas';
@override
String get trafficMonth => 'Tráfico';
@override
String get avgPing => 'Ping medio';
@override
String get durMonth => 'Tiempo';
@override
String get weekTraffic => 'Esta semana (GB)';
@override
List<String> get days7 => const ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
@override
String get byDevice => 'Por dispositivo';
@override
String get noDeviceUsage => 'Aún no hay uso por dispositivo';
@override
String get periodMonth => 'Este mes';
@override
String get periodWeek => 'Esta semana';
@override
String get periodToday => 'Hoy';
@override
String get statDown => 'Descarga';
@override
String get statUp => 'Subida';
@override
String get statDuration => 'Duración';
@override
String get chartTwoWeeks => 'Últimas 2 semanas';
@override
String get allDevices => 'Todos los dispositivos';
@override
String get meTitle => 'Cuenta';
@override
String get proMember => 'Miembro PRO';
@override
String get freePlanName => 'Gratis';
@override
String get expires => 'Vence';
@override
String get upgradeBtn => 'Renovar / Mejorar';
@override
String get accInfoTitle => 'Información de la cuenta';
@override
String get accEmail => 'Correo';
@override
String get accPassword => 'Contraseña';
@override
String get accChange => 'Cambiar';
@override
String get deviceLimitTitle => 'Límite de dispositivos alcanzado';
@override
String get deviceLimitDesc => 'Tu plan permite hasta %s dispositivos. Elimina uno para continuar.';
@override
String get deviceLimitRemoveOldest => 'Eliminar el menos usado recientemente';
@override
String get myDevices => 'Mis dispositivos';
@override
String get devicesSub => 'Hasta 3 dispositivos en PRO';
@override
String get thisDevice => 'Este dispositivo';
@override
String get remove => 'Eliminar';
@override
String get devOnline => 'En línea';
@override
String get devOffline => 'Desconectado';
@override
String get devLastOnline => 'Última conexión';
@override
String get devNeverLogin => 'Nunca ha iniciado sesión';
@override
String get devForceLogout => 'Forzar cierre de sesión';
@override
String get devClearLogin => 'Borrar datos de sesión';
@override
String get devRename => 'Renombrar';
@override
String get devRenameHint => 'Nombre del dispositivo';
@override
String get devSave => 'Guardar';
@override
String get devForceLogoutConfirm => 'Esto revoca la sesión del dispositivo: será desconectado y deberá iniciar sesión de nuevo. El dispositivo permanece en la lista.';
@override
String get devClearLoginConfirm => 'Esto elimina el dispositivo de la lista por completo y revoca su sesión y su credencial del plano de datos. El dispositivo deberá iniciar sesión de nuevo para poder usarse.';
@override
String get devCancel => 'Cancelar';
@override
String get sessionRevokedTitle => 'Se cerró la sesión de este dispositivo';
@override
String get sessionRevokedBody => 'Tu cuenta cerró la sesión de este dispositivo desde otro dispositivo. Inicia sesión de nuevo.';
@override
String get sessionRevokedOk => 'Iniciar sesión de nuevo';
@override
String get redeemEntry => 'Canjear y comprar';
@override
String get contactEntry => 'Contáctanos';
@override
String get signOut => 'Cerrar sesión';
@override
String get language => 'Idioma';
@override
String get darkAppearance => 'Apariencia oscura';
@override
String get stateOn => 'Activado';
@override
String get followLight => 'Desactivado';
@override
String get protocol => 'Protocolo';
@override
String get settingsTitle => 'Ajustes';
@override
String get autostart => 'Iniciar al arrancar';
@override
String get autostartSub => 'Ejecutar automáticamente al iniciar el sistema';
@override
String get autoConnect => 'Conexión automática';
@override
String get autoConnectSub => 'Conectar automáticamente al abrir';
@override
String autoConnectingTo(String name) => 'Conectando automáticamente a $name';
@override
String get smartRoute => 'Enrutamiento inteligente';
@override
String get smartRouteSub => 'Acelera en el extranjero, directo en casa';
@override
String get killSwitch => 'Kill Switch';
@override
String get killSwitchSub => 'Bloquea el tráfico si se cae el enlace';
@override
String get checkUpdate => 'Buscar actualizaciones';
@override
String get webUserCenter => 'Centro de usuario (Web)';
@override
String updateAvailableTitle(String version) => 'Nueva versión v$version disponible';
@override
String get updateNotesFallback => 'Esta actualización incluye correcciones y mejoras de estabilidad.';
@override
String get updateLater => 'Más tarde';
@override
String get updateDownload => 'Descargar actualización';
@override
String get updateUpToDate => 'Ya tienes la última versión';
@override
String get updateCheckFailed => 'Falló la búsqueda de actualizaciones, inténtalo más tarde';
@override
String get choosePlan => 'Elegir plan';
@override
String get freePlan => 'Gratis';
@override
String get proPlan => 'Pro';
@override
String get teamPlan => 'Equipo';
@override
String get perMonth => '/mes';
@override
String get current => 'Actual';
@override
String get upgrade => 'Mejorar';
@override
String get choose => 'Elegir';
@override
String get mostPopular => 'Popular';
@override
List<String> get featsFree => const [
'Solo 1 nodo básico',
'10 min al día',
'Mira un anuncio para empezar',
'Prueba gratis de 7 días al registrarte',
];
@override
List<String> get featsPro =>
const ['Más de 80 ubicaciones', 'Ilimitado · rápido', '5 dispositivos', 'Optimizado para streaming'];
@override
List<String> get featsTeam =>
const ['Todo lo de Pro', '10 plazas', 'Facturación centralizada', 'Soporte prioritario'];
@override
String get redeemTitle => 'Canjear y comprar';
@override
String get redeemCodeTitle => 'Canjear un código';
@override
String get redeemPh => 'Introduce el código de activación';
@override
String get redeemBtn => 'Canjear';
@override
String get redeemOk => 'Activado · PRO desbloqueado';
@override
String get buyTitle => 'Dónde comprar';
@override
String get buySub => 'El pago dentro de la app no está disponible. Consigue un código mediante:';
@override
String get chStore => 'Tienda autoservicio';
@override
String get chEmail => 'Soporte por correo';
@override
String get contactTitle => 'Contáctanos';
@override
String get contactIntro =>
'¿Necesitas ayuda? Escríbenos por cualquiera de los canales de abajo: solemos responder en minutos.';
@override
String get contactEmail => 'Soporte por correo';
@override
String get contactStore => 'Tienda autoservicio';
@override
String get contactHoursTitle => 'Horario de soporte';
@override
String get contactHours => 'Todos los días 9:00 24:00 (GMT+8)';
@override
String get authTagline => 'Rápido · Estable · Sin complicaciones';
@override
String get tabLogin => 'Iniciar sesión';
@override
String get tabRegister => 'Registrarse';
@override
String get emailLabel => 'Correo';
@override
String get emailPh => 'tu@correo.com';
@override
String get pwLabel => 'Contraseña';
@override
String get pwPh => 'Introduce la contraseña';
@override
String get setPwPh => 'Crea una contraseña (para acceso multidispositivo)';
@override
String get codeLabel => 'Código de verificación';
@override
String codeSentTo(String email) => 'Código enviado a $email';
@override
String get sendCode => 'Enviar código';
@override
String get resend => 'Reenviar';
@override
String get doLogin => 'Iniciar sesión';
@override
String get doNext => 'Siguiente';
@override
String get doCreate => 'Crear cuenta';
@override
String get forgotPw => '¿Olvidaste la contraseña?';
@override
String get tos => 'Al continuar aceptas nuestros Términos y Política de privacidad';
@override
String get obSkip => 'Omitir';
@override
String get obNext => 'Siguiente';
@override
String get obAllow => 'Permitir y continuar';
@override
String get obStart => 'Empezar';
@override
String get ob1Title => 'Conéctate en todo el mundo';
@override
String get ob1Sub => 'Más de 80 ubicaciones, enrutamiento al más rápido automático, sólido como una roca.';
@override
String get ob2Title => 'Permite la configuración de red';
@override
String get ob2Sub => 'El sistema te pedirá añadir un perfil de red para crear el túnel cifrado.';
@override
String get ob3Title => 'Privado por diseño';
@override
String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.';
}
+373
View File
@@ -0,0 +1,373 @@
// strings_ja.dart — 日本語の文言リソース(単一言語表示)
//
// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。
// ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。
// プラン番号は design/CLAUDE.md §7 に準拠。
import 'app_text.dart';
class StringsJa extends AppText {
const StringsJa();
@override
AppLang get lang => AppLang.ja;
@override
String get brand => 'Pangolin';
@override
String get online => '● オンライン';
@override
String get offline => '○ オフライン';
@override
String get capOff => 'タップして接続';
@override
String get capConnecting => '接続中…';
@override
String get capOn => '接続済み · 暗号化';
@override
String get connectNow => '接続';
@override
String get secure => 'セキュア';
@override
String get tabConnect => '接続';
@override
String get tabServers => 'サーバー';
@override
String get tabStats => '統計';
@override
String get tabMe => 'アカウント';
@override
String get currentNode => '現在のノード';
@override
String get download => '下り';
@override
String get upload => '上り';
@override
String get latency => 'Ping';
@override
String get quotaToday => '本日の残り';
@override
String get minutes => '';
@override
String get quotaFree => '無料 · 1日10分';
@override
String get planFreeTag => '無料';
@override
String get watchAd => '広告を見て開始';
@override
String get adUnlocked => '本日は利用可能';
@override
String get quotaLeftLabel => '残り';
@override
String get quotaUsedUp => '本日の無料時間を使い切りました';
@override
String get watchAdMore => '広告を見て時間を追加';
@override
String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください。';
@override
String get adPlaying => '広告を再生中…';
@override
String adRewarded(int minutes) => '+$minutes 分を追加しました';
@override
String get adFailed => '時間の追加に失敗しました。もう一度お試しください';
@override
String get quotaDesktopTitle => '本日の無料時間を使い切りました';
@override
String get quotaDesktopBody => 'モバイルアプリで広告を見ると時間を追加できます。無制限に使うにはアップグレードしてください。';
@override
String get gotIt => '了解';
@override
String get chooseNode => 'サーバーを選択';
@override
String get searchPh => '国・都市を検索';
@override
String get smartSelect => 'スマート選択';
@override
String nodeSwitchTitle(String name) => '$name に切り替えますか?';
@override
String get nodeSwitchBody => '現在の接続を切断し、このノードに再接続します。';
@override
String get nodeSwitchConfirm => '切り替える';
@override
String get nodeUnhealthySwitched => '現在のノードに接続できないため、自動的に切り替えました';
@override
String get nodeUnhealthyError => '現在のノードに接続できません。再接続するかノードを切り替えてください';
@override
String get nodeReconnecting => 'ネットワークが不安定です。再接続中…';
@override
String get smartSub => 'お使いのネットワークに最適なノードを選びます';
@override
String get recommended => 'おすすめ';
@override
String get statsTitle => '統計';
@override
String get trafficMonth => '通信量';
@override
String get avgPing => '平均Ping';
@override
String get durMonth => '接続時間';
@override
String get weekTraffic => '今週 (GB)';
@override
List<String> get days7 => const ['', '', '', '', '', '', ''];
@override
String get byDevice => 'デバイス別';
@override
String get noDeviceUsage => 'デバイスの利用履歴はまだありません';
@override
String get periodMonth => '今月';
@override
String get periodWeek => '今週';
@override
String get periodToday => '今日';
@override
String get statDown => 'ダウンロード';
@override
String get statUp => 'アップロード';
@override
String get statDuration => '接続時間';
@override
String get chartTwoWeeks => '過去2週間';
@override
String get allDevices => 'すべてのデバイス';
@override
String get meTitle => 'アカウント';
@override
String get proMember => 'PRO会員';
@override
String get freePlanName => '無料';
@override
String get expires => '有効期限';
@override
String get upgradeBtn => '更新 / アップグレード';
@override
String get accInfoTitle => 'アカウント情報';
@override
String get accEmail => 'メールアドレス';
@override
String get accPassword => 'パスワード';
@override
String get accChange => '変更';
@override
String get deviceLimitTitle => 'デバイス数の上限に達しました';
@override
String get deviceLimitDesc => '現在のプランでは最大 %s 台まで利用できます。続けるには1台を削除してください。';
@override
String get deviceLimitRemoveOldest => '最後に使用したのが最も古いデバイスを削除';
@override
String get myDevices => 'マイデバイス';
@override
String get devicesSub => 'PROでは最大3台まで';
@override
String get thisDevice => 'このデバイス';
@override
String get remove => '削除';
@override
String get devOnline => 'オンライン';
@override
String get devOffline => 'オフライン';
@override
String get devLastOnline => '最終オンライン';
@override
String get devNeverLogin => 'ログイン履歴なし';
@override
String get devForceLogout => '強制ログアウト';
@override
String get devClearLogin => 'ログイン情報を消去';
@override
String get devRename => '名前を変更';
@override
String get devRenameHint => 'デバイス名';
@override
String get devSave => '保存';
@override
String get devForceLogoutConfirm => 'このデバイスのログインセッションを無効にします。オフラインになり、再度サインインが必要になります。デバイスはリストに残ります。';
@override
String get devClearLoginConfirm => 'このデバイスをリストから完全に削除し、ログインセッションとデータ通信の認証情報を無効にします。再び利用するには、もう一度サインインが必要です。';
@override
String get devCancel => 'キャンセル';
@override
String get sessionRevokedTitle => 'このデバイスはサインアウトされました';
@override
String get sessionRevokedBody => '別のデバイスから、このデバイスがサインアウトされました。もう一度サインインしてください。';
@override
String get sessionRevokedOk => '再度サインイン';
@override
String get redeemEntry => 'コード引き換え・購入';
@override
String get contactEntry => 'お問い合わせ';
@override
String get signOut => 'サインアウト';
@override
String get language => '言語';
@override
String get darkAppearance => 'ダークモード';
@override
String get stateOn => 'オン';
@override
String get followLight => 'オフ';
@override
String get protocol => 'プロトコル';
@override
String get settingsTitle => '設定';
@override
String get autostart => 'スタートアップ時に起動';
@override
String get autostartSub => 'システム起動時に自動的に実行';
@override
String get autoConnect => '自動接続';
@override
String get autoConnectSub => '起動時に自動的に接続';
@override
String autoConnectingTo(String name) => '$name に自動接続中…';
@override
String get smartRoute => 'スマートルーティング';
@override
String get smartRouteSub => '海外は高速化、国内は直接接続';
@override
String get killSwitch => 'キルスイッチ';
@override
String get killSwitchSub => '接続が切れたら通信を遮断';
@override
String get checkUpdate => 'アップデートを確認';
@override
String get webUserCenter => 'ユーザーセンター (Web)';
@override
String updateAvailableTitle(String version) => '新しいバージョン v$version が利用可能です';
@override
String get updateNotesFallback => 'このアップデートには不具合の修正と安定性の向上が含まれています。';
@override
String get updateLater => '後で';
@override
String get updateDownload => 'アップデートをダウンロード';
@override
String get updateUpToDate => '最新の状態です';
@override
String get updateCheckFailed => 'アップデートの確認に失敗しました。後でもう一度お試しください';
@override
String get choosePlan => 'プランを選択';
@override
String get freePlan => '無料';
@override
String get proPlan => 'Pro';
@override
String get teamPlan => 'Team';
@override
String get perMonth => '/月';
@override
String get current => '現在';
@override
String get upgrade => 'アップグレード';
@override
String get choose => '選択';
@override
String get mostPopular => '人気';
@override
List<String> get featsFree => const [
'基本ノード1つのみ',
'1日10分',
'広告を見て開始',
'登録で7日間無料トライアル',
];
@override
List<String> get featsPro =>
const ['80以上の拠点', '無制限 · 高速', '5台のデバイス', 'ストリーミング最適化'];
@override
List<String> get featsTeam =>
const ['Proのすべての機能', '10ライセンス', '一括請求', '優先サポート'];
@override
String get redeemTitle => 'コード引き換え・購入';
@override
String get redeemCodeTitle => 'コードを引き換える';
@override
String get redeemPh => 'アクティベーションコードを入力';
@override
String get redeemBtn => '引き換える';
@override
String get redeemOk => '認証完了 · PROが利用可能に';
@override
String get buyTitle => '購入方法';
@override
String get buySub => 'アプリ内決済はご利用いただけません。以下からコードを入手してください:';
@override
String get chStore => 'セルフサービスストア';
@override
String get chEmail => 'メールサポート';
@override
String get contactTitle => 'お問い合わせ';
@override
String get contactIntro =>
'お困りですか?以下のいずれかの方法でお問い合わせください。通常は数分以内に返信します。';
@override
String get contactEmail => 'メールサポート';
@override
String get contactStore => 'セルフサービスストア';
@override
String get contactHoursTitle => 'サポート時間';
@override
String get contactHours => '毎日 9:00 24:00 (GMT+8)';
@override
String get authTagline => '高速 · 安定 · かんたん';
@override
String get tabLogin => 'ログイン';
@override
String get tabRegister => '新規登録';
@override
String get emailLabel => 'メールアドレス';
@override
String get emailPh => 'your@email.com';
@override
String get pwLabel => 'パスワード';
@override
String get pwPh => 'パスワードを入力';
@override
String get setPwPh => 'パスワードを設定(複数デバイスでのログイン用)';
@override
String get codeLabel => '確認コード';
@override
String codeSentTo(String email) => '$email に確認コードを送信しました';
@override
String get sendCode => 'コードを送信';
@override
String get resend => '再送信';
@override
String get doLogin => 'ログイン';
@override
String get doNext => '次へ';
@override
String get doCreate => 'アカウントを作成';
@override
String get forgotPw => 'パスワードをお忘れですか?';
@override
String get tos => '続行すると、利用規約とプライバシーポリシーに同意したことになります';
@override
String get obSkip => 'スキップ';
@override
String get obNext => '次へ';
@override
String get obAllow => '許可して続行';
@override
String get obStart => '始める';
@override
String get ob1Title => '世界中に接続';
@override
String get ob1Sub => '80以上の拠点、最速ルートを自動選択、抜群の安定性。';
@override
String get ob2Title => 'ネットワーク設定を許可';
@override
String get ob2Sub => '暗号化トンネルを構築するため、システムがネットワークプロファイルの追加を求めます。';
@override
String get ob3Title => '設計段階からプライバシー重視';
@override
String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。';
}
+373
View File
@@ -0,0 +1,373 @@
// strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시)
//
// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere.
// 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시.
// 요금제 번호는 design/CLAUDE.md §7 을 따름.
import 'app_text.dart';
class StringsKo extends AppText {
const StringsKo();
@override
AppLang get lang => AppLang.ko;
@override
String get brand => 'Pangolin';
@override
String get online => '● 온라인';
@override
String get offline => '○ 오프라인';
@override
String get capOff => '탭하여 연결';
@override
String get capConnecting => '연결 중…';
@override
String get capOn => '연결됨 · 암호화';
@override
String get connectNow => '연결';
@override
String get secure => '보안';
@override
String get tabConnect => '연결';
@override
String get tabServers => '서버';
@override
String get tabStats => '통계';
@override
String get tabMe => '계정';
@override
String get currentNode => '현재 노드';
@override
String get download => '다운';
@override
String get upload => '';
@override
String get latency => '지연';
@override
String get quotaToday => '오늘 남은 시간';
@override
String get minutes => '';
@override
String get quotaFree => '무료 · 하루 10분';
@override
String get planFreeTag => '무료';
@override
String get watchAd => '광고 시청 후 시작';
@override
String get adUnlocked => '오늘 사용 가능';
@override
String get quotaLeftLabel => '남음';
@override
String get quotaUsedUp => '오늘 무료 시간을 모두 사용함';
@override
String get watchAdMore => '광고 시청으로 시간 추가';
@override
String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.';
@override
String get adPlaying => '광고 재생 중…';
@override
String adRewarded(int minutes) => '+$minutes분 추가됨';
@override
String get adFailed => '시간 추가에 실패했습니다. 다시 시도하세요';
@override
String get quotaDesktopTitle => '오늘 무료 시간을 모두 사용함';
@override
String get quotaDesktopBody => '모바일 앱에서 광고를 시청해 시간을 추가하거나, 업그레이드하여 무제한으로 이용하세요.';
@override
String get gotIt => '확인';
@override
String get chooseNode => '서버 선택';
@override
String get searchPh => '국가 / 도시 검색';
@override
String get smartSelect => '스마트 선택';
@override
String nodeSwitchTitle(String name) => '$name(으)로 전환할까요?';
@override
String get nodeSwitchBody => '현재 연결을 끊고 이 노드로 다시 연결합니다.';
@override
String get nodeSwitchConfirm => '전환';
@override
String get nodeUnhealthySwitched => '현재 노드에 연결할 수 없어 자동으로 전환했습니다';
@override
String get nodeUnhealthyError => '현재 노드에 연결할 수 없습니다 — 재연결하거나 노드를 전환하세요';
@override
String get nodeReconnecting => '네트워크가 불안정하여 다시 연결 중…';
@override
String get smartSub => '네트워크에 가장 적합한 노드를 선택합니다';
@override
String get recommended => '추천';
@override
String get statsTitle => '통계';
@override
String get trafficMonth => '트래픽';
@override
String get avgPing => '평균 지연';
@override
String get durMonth => '시간';
@override
String get weekTraffic => '이번 주 (GB)';
@override
List<String> get days7 => const ['', '', '', '', '', '', ''];
@override
String get byDevice => '기기별';
@override
String get noDeviceUsage => '아직 기기 사용 기록이 없습니다';
@override
String get periodMonth => '이번 달';
@override
String get periodWeek => '이번 주';
@override
String get periodToday => '오늘';
@override
String get statDown => '다운로드';
@override
String get statUp => '업로드';
@override
String get statDuration => '사용 시간';
@override
String get chartTwoWeeks => '최근 2주';
@override
String get allDevices => '모든 기기';
@override
String get meTitle => '계정';
@override
String get proMember => 'PRO 회원';
@override
String get freePlanName => '무료';
@override
String get expires => '만료일';
@override
String get upgradeBtn => '갱신 / 업그레이드';
@override
String get accInfoTitle => '계정 정보';
@override
String get accEmail => '이메일';
@override
String get accPassword => '비밀번호';
@override
String get accChange => '변경';
@override
String get deviceLimitTitle => '기기 한도에 도달함';
@override
String get deviceLimitDesc => '현재 요금제는 최대 %s대의 기기를 허용합니다. 계속하려면 한 대를 삭제하세요.';
@override
String get deviceLimitRemoveOldest => '가장 오래 사용하지 않은 기기 삭제';
@override
String get myDevices => '내 기기';
@override
String get devicesSub => 'PRO는 최대 3대';
@override
String get thisDevice => '이 기기';
@override
String get remove => '삭제';
@override
String get devOnline => '온라인';
@override
String get devOffline => '오프라인';
@override
String get devLastOnline => '마지막 접속';
@override
String get devNeverLogin => '로그인한 적 없음';
@override
String get devForceLogout => '강제 로그아웃';
@override
String get devClearLogin => '로그인 정보 삭제';
@override
String get devRename => '이름 변경';
@override
String get devRenameHint => '기기 이름';
@override
String get devSave => '저장';
@override
String get devForceLogoutConfirm => '이 기기의 로그인 세션을 무효화합니다 — 오프라인으로 강제 종료되며 다시 로그인해야 합니다. 기기는 목록에 남아 있습니다.';
@override
String get devClearLoginConfirm => '이 기기를 목록에서 완전히 삭제하고 로그인 세션과 데이터 전송 자격 증명을 무효화합니다. 다시 사용하려면 재로그인해야 합니다.';
@override
String get devCancel => '취소';
@override
String get sessionRevokedTitle => '이 기기에서 로그아웃되었습니다';
@override
String get sessionRevokedBody => '다른 기기에서 이 기기를 로그아웃했습니다. 다시 로그인하세요.';
@override
String get sessionRevokedOk => '다시 로그인';
@override
String get redeemEntry => '코드 등록 및 구매';
@override
String get contactEntry => '문의하기';
@override
String get signOut => '로그아웃';
@override
String get language => '언어';
@override
String get darkAppearance => '다크 모드';
@override
String get stateOn => '켜짐';
@override
String get followLight => '꺼짐';
@override
String get protocol => '프로토콜';
@override
String get settingsTitle => '설정';
@override
String get autostart => '시작 시 실행';
@override
String get autostartSub => '시스템 시작 시 자동으로 실행';
@override
String get autoConnect => '자동 연결';
@override
String get autoConnectSub => '실행 시 자동으로 연결';
@override
String autoConnectingTo(String name) => '$name(으)로 자동 연결 중…';
@override
String get smartRoute => '스마트 라우팅';
@override
String get smartRouteSub => '해외는 가속, 국내는 직접 연결';
@override
String get killSwitch => 'Kill Switch';
@override
String get killSwitchSub => '연결이 끊기면 트래픽 차단';
@override
String get checkUpdate => '업데이트 확인';
@override
String get webUserCenter => '사용자 센터 (웹)';
@override
String updateAvailableTitle(String version) => '새 버전 v$version 사용 가능';
@override
String get updateNotesFallback => '이번 업데이트에는 버그 수정과 안정성 개선이 포함되어 있습니다.';
@override
String get updateLater => '나중에';
@override
String get updateDownload => '업데이트 다운로드';
@override
String get updateUpToDate => '최신 버전입니다';
@override
String get updateCheckFailed => '업데이트 확인에 실패했습니다. 잠시 후 다시 시도하세요';
@override
String get choosePlan => '요금제 선택';
@override
String get freePlan => '무료';
@override
String get proPlan => 'Pro';
@override
String get teamPlan => 'Team';
@override
String get perMonth => '/월';
@override
String get current => '현재';
@override
String get upgrade => '업그레이드';
@override
String get choose => '선택';
@override
String get mostPopular => '인기';
@override
List<String> get featsFree => const [
'기본 노드 1개',
'하루 10분',
'광고 시청 후 시작',
'가입 시 7일 무료 체험',
];
@override
List<String> get featsPro =>
const ['80개 이상 지역', '무제한 · 빠른 속도', '기기 5대', '스트리밍 최적화'];
@override
List<String> get featsTeam =>
const ['Pro의 모든 기능', '10인 좌석', '통합 결제', '우선 지원'];
@override
String get redeemTitle => '코드 등록 및 구매';
@override
String get redeemCodeTitle => '코드 등록';
@override
String get redeemPh => '활성화 코드 입력';
@override
String get redeemBtn => '등록';
@override
String get redeemOk => '활성화됨 · PRO 잠금 해제';
@override
String get buyTitle => '구매처';
@override
String get buySub => '앱 내 결제는 지원되지 않습니다. 다음 경로로 코드를 받으세요:';
@override
String get chStore => '셀프 스토어';
@override
String get chEmail => '이메일 지원';
@override
String get contactTitle => '문의하기';
@override
String get contactIntro =>
'도움이 필요하신가요? 아래 채널로 문의하세요 — 보통 몇 분 안에 답변드립니다.';
@override
String get contactEmail => '이메일 지원';
@override
String get contactStore => '셀프 스토어';
@override
String get contactHoursTitle => '지원 시간';
@override
String get contactHours => '매일 9:00 24:00 (GMT+8)';
@override
String get authTagline => '빠르게 · 안정적으로 · 손쉽게';
@override
String get tabLogin => '로그인';
@override
String get tabRegister => '회원가입';
@override
String get emailLabel => '이메일';
@override
String get emailPh => 'your@email.com';
@override
String get pwLabel => '비밀번호';
@override
String get pwPh => '비밀번호 입력';
@override
String get setPwPh => '비밀번호 설정 (다중 기기 로그인용)';
@override
String get codeLabel => '인증 코드';
@override
String codeSentTo(String email) => '$email(으)로 코드를 보냈습니다';
@override
String get sendCode => '코드 전송';
@override
String get resend => '재전송';
@override
String get doLogin => '로그인';
@override
String get doNext => '다음';
@override
String get doCreate => '계정 만들기';
@override
String get forgotPw => '비밀번호를 잊으셨나요?';
@override
String get tos => '계속하면 이용약관 및 개인정보 처리방침에 동의하는 것입니다';
@override
String get obSkip => '건너뛰기';
@override
String get obNext => '다음';
@override
String get obAllow => '허용하고 계속';
@override
String get obStart => '시작하기';
@override
String get ob1Title => '전 세계로 연결';
@override
String get ob1Sub => '80개 이상의 지역, 자동 최적 경로, 안정적인 연결.';
@override
String get ob2Title => '네트워크 설정 허용';
@override
String get ob2Sub => '암호화된 터널을 구성하기 위해 시스템이 네트워크 프로필 추가를 요청합니다.';
@override
String get ob3Title => '설계부터 프라이버시';
@override
String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.';
}
+373
View File
@@ -0,0 +1,373 @@
// strings_ru.dart — русские текстовые ресурсы (отображение на одном языке)
//
// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН.
// Позиционирование: ускорение сети / оптимизация опыта + приватность.
// Номера тарифов соответствуют design/CLAUDE.md §7.
import 'app_text.dart';
class StringsRu extends AppText {
const StringsRu();
@override
AppLang get lang => AppLang.ru;
@override
String get brand => 'Pangolin';
@override
String get online => 'В сети';
@override
String get offline => '○ Не в сети';
@override
String get capOff => 'Нажмите для подключения';
@override
String get capConnecting => 'Подключение…';
@override
String get capOn => 'Подключено · Шифрование';
@override
String get connectNow => 'ПОДКЛЮЧИТЬ';
@override
String get secure => 'ЗАЩИЩЕНО';
@override
String get tabConnect => 'Подключение';
@override
String get tabServers => 'Серверы';
@override
String get tabStats => 'Статистика';
@override
String get tabMe => 'Аккаунт';
@override
String get currentNode => 'Текущий узел';
@override
String get download => 'Приём';
@override
String get upload => 'Отдача';
@override
String get latency => 'Пинг';
@override
String get quotaToday => 'Осталось сегодня';
@override
String get minutes => 'мин';
@override
String get quotaFree => 'Бесплатно · 10 мин/день';
@override
String get planFreeTag => 'Бесплатно';
@override
String get watchAd => 'Смотрите рекламу, чтобы начать';
@override
String get adUnlocked => 'Открыто на сегодня';
@override
String get quotaLeftLabel => 'Осталось';
@override
String get quotaUsedUp => 'Дневной бесплатный лимит исчерпан';
@override
String get watchAdMore => 'Смотрите рекламу, чтобы добавить время';
@override
String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.';
@override
String get adPlaying => 'Реклама воспроизводится…';
@override
String adRewarded(int minutes) => '+$minutes мин добавлено';
@override
String get adFailed => 'Не удалось добавить время, попробуйте ещё раз';
@override
String get quotaDesktopTitle => 'Дневной бесплатный лимит исчерпан';
@override
String get quotaDesktopBody => 'Смотрите рекламу в мобильном приложении, чтобы добавить время, или оформите подписку для безлимитного доступа.';
@override
String get gotIt => 'Понятно';
@override
String get chooseNode => 'Выбор сервера';
@override
String get searchPh => 'Поиск страны / города';
@override
String get smartSelect => 'Умный выбор';
@override
String nodeSwitchTitle(String name) => 'Переключиться на $name?';
@override
String get nodeSwitchBody => 'Текущее соединение будет разорвано и восстановлено через этот узел.';
@override
String get nodeSwitchConfirm => 'Переключить';
@override
String get nodeUnhealthySwitched => 'Текущий узел недоступен — переключение выполнено автоматически';
@override
String get nodeUnhealthyError => 'Текущий узел недоступен — переподключитесь или смените узел';
@override
String get nodeReconnecting => 'Сеть нестабильна, переподключение…';
@override
String get smartSub => 'Подбирает лучший узел для вашей сети';
@override
String get recommended => 'Рекомендуется';
@override
String get statsTitle => 'Статистика';
@override
String get trafficMonth => 'Трафик';
@override
String get avgPing => 'Средний пинг';
@override
String get durMonth => 'Время';
@override
String get weekTraffic => 'На этой неделе (ГБ)';
@override
List<String> get days7 => const ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
@override
String get byDevice => 'По устройствам';
@override
String get noDeviceUsage => 'Пока нет данных по устройствам';
@override
String get periodMonth => 'Этот месяц';
@override
String get periodWeek => 'Эта неделя';
@override
String get periodToday => 'Сегодня';
@override
String get statDown => 'Приём';
@override
String get statUp => 'Отдача';
@override
String get statDuration => 'Длительность';
@override
String get chartTwoWeeks => 'Последние 2 недели';
@override
String get allDevices => 'Все устройства';
@override
String get meTitle => 'Аккаунт';
@override
String get proMember => 'PRO-подписка';
@override
String get freePlanName => 'Бесплатный';
@override
String get expires => 'Действует до';
@override
String get upgradeBtn => 'Продлить / Улучшить';
@override
String get accInfoTitle => 'Данные аккаунта';
@override
String get accEmail => 'Эл. почта';
@override
String get accPassword => 'Пароль';
@override
String get accChange => 'Изменить';
@override
String get deviceLimitTitle => 'Достигнут лимит устройств';
@override
String get deviceLimitDesc => 'Ваш тариф допускает до %s устройств. Удалите одно, чтобы продолжить.';
@override
String get deviceLimitRemoveOldest => 'Удалить давно неиспользуемое';
@override
String get myDevices => 'Мои устройства';
@override
String get devicesSub => 'До 3 устройств на PRO';
@override
String get thisDevice => 'Это устройство';
@override
String get remove => 'Удалить';
@override
String get devOnline => 'В сети';
@override
String get devOffline => 'Не в сети';
@override
String get devLastOnline => 'Был(а) в сети';
@override
String get devNeverLogin => 'Ни разу не входил(а)';
@override
String get devForceLogout => 'Принудительный выход';
@override
String get devClearLogin => 'Очистить данные входа';
@override
String get devRename => 'Переименовать';
@override
String get devRenameHint => 'Имя устройства';
@override
String get devSave => 'Сохранить';
@override
String get devForceLogoutConfirm => 'Это завершит сеанс входа устройства — оно будет отключено и потребует повторного входа. Устройство останется в списке.';
@override
String get devClearLoginConfirm => 'Это полностью удалит устройство из списка и отзовёт его сеанс входа и учётные данные передачи данных. Чтобы снова использовать устройство, нужно войти заново.';
@override
String get devCancel => 'Отмена';
@override
String get sessionRevokedTitle => 'Это устройство было отключено';
@override
String get sessionRevokedBody => 'Вход с этого устройства завершён с другого устройства вашего аккаунта. Пожалуйста, войдите снова.';
@override
String get sessionRevokedOk => 'Войти снова';
@override
String get redeemEntry => 'Активация и покупка';
@override
String get contactEntry => 'Связаться с нами';
@override
String get signOut => 'Выйти';
@override
String get language => 'Язык';
@override
String get darkAppearance => 'Тёмная тема';
@override
String get stateOn => 'Вкл.';
@override
String get followLight => 'Выкл.';
@override
String get protocol => 'Протокол';
@override
String get settingsTitle => 'Настройки';
@override
String get autostart => 'Запуск при старте системы';
@override
String get autostartSub => 'Запускать автоматически при включении системы';
@override
String get autoConnect => 'Автоподключение';
@override
String get autoConnectSub => 'Подключаться автоматически при запуске';
@override
String autoConnectingTo(String name) => 'Автоподключение к $name';
@override
String get smartRoute => 'Умная маршрутизация';
@override
String get smartRouteSub => 'Ускорение для зарубежных ресурсов, прямой доступ для локальных';
@override
String get killSwitch => 'Kill Switch';
@override
String get killSwitchSub => 'Блокировать трафик при разрыве соединения';
@override
String get checkUpdate => 'Проверить обновления';
@override
String get webUserCenter => 'Личный кабинет (веб)';
@override
String updateAvailableTitle(String version) => 'Доступна новая версия v$version';
@override
String get updateNotesFallback => 'Это обновление включает исправления и улучшения стабильности.';
@override
String get updateLater => 'Позже';
@override
String get updateDownload => 'Скачать обновление';
@override
String get updateUpToDate => 'У вас установлена последняя версия';
@override
String get updateCheckFailed => 'Не удалось проверить обновления, попробуйте позже';
@override
String get choosePlan => 'Выбор тарифа';
@override
String get freePlan => 'Бесплатный';
@override
String get proPlan => 'Pro';
@override
String get teamPlan => 'Команда';
@override
String get perMonth => '/мес';
@override
String get current => 'Текущий';
@override
String get upgrade => 'Улучшить';
@override
String get choose => 'Выбрать';
@override
String get mostPopular => 'Популярный';
@override
List<String> get featsFree => const [
'Только 1 базовый узел',
'10 минут в день',
'Смотрите рекламу, чтобы начать',
'7 дней бесплатно при регистрации',
];
@override
List<String> get featsPro =>
const ['80+ локаций', 'Безлимитно · быстро', '5 устройств', 'Оптимизация для стриминга'];
@override
List<String> get featsTeam =>
const ['Всё из Pro', '10 мест', 'Единый счёт', 'Приоритетная поддержка'];
@override
String get redeemTitle => 'Активация и покупка';
@override
String get redeemCodeTitle => 'Активировать код';
@override
String get redeemPh => 'Введите код активации';
@override
String get redeemBtn => 'Активировать';
@override
String get redeemOk => 'Активировано · PRO открыт';
@override
String get buyTitle => 'Где купить';
@override
String get buySub => 'Оплата внутри приложения недоступна. Получить код можно через:';
@override
String get chStore => 'Магазин самообслуживания';
@override
String get chEmail => 'Поддержка по эл. почте';
@override
String get contactTitle => 'Связаться с нами';
@override
String get contactIntro =>
'Нужна помощь? Свяжитесь с нами любым способом ниже — обычно отвечаем за считанные минуты.';
@override
String get contactEmail => 'Поддержка по эл. почте';
@override
String get contactStore => 'Магазин самообслуживания';
@override
String get contactHoursTitle => 'Часы поддержки';
@override
String get contactHours => 'Ежедневно 9:00 24:00 (GMT+8)';
@override
String get authTagline => 'Быстро · Стабильно · Без усилий';
@override
String get tabLogin => 'Вход';
@override
String get tabRegister => 'Регистрация';
@override
String get emailLabel => 'Эл. почта';
@override
String get emailPh => 'your@email.com';
@override
String get pwLabel => 'Пароль';
@override
String get pwPh => 'Введите пароль';
@override
String get setPwPh => 'Задайте пароль (для входа с нескольких устройств)';
@override
String get codeLabel => 'Код подтверждения';
@override
String codeSentTo(String email) => 'Код отправлен на $email';
@override
String get sendCode => 'Отправить код';
@override
String get resend => 'Отправить снова';
@override
String get doLogin => 'Войти';
@override
String get doNext => 'Далее';
@override
String get doCreate => 'Создать аккаунт';
@override
String get forgotPw => 'Забыли пароль?';
@override
String get tos => 'Продолжая, вы принимаете наши Условия и Политику конфиденциальности';
@override
String get obSkip => 'Пропустить';
@override
String get obNext => 'Далее';
@override
String get obAllow => 'Разрешить и продолжить';
@override
String get obStart => 'Начать';
@override
String get ob1Title => 'Подключение по всему миру';
@override
String get ob1Sub => '80+ локаций, автоматический выбор самого быстрого маршрута, надёжная стабильность.';
@override
String get ob2Title => 'Разрешите настройку сети';
@override
String get ob2Sub => 'Система запросит добавление сетевого профиля для создания зашифрованного туннеля.';
@override
String get ob3Title => 'Приватность по умолчанию';
@override
String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.';
}
+38
View File
@@ -52,9 +52,31 @@ class StringsZh extends AppText {
@override
String get quotaFree => '免费版 · 每日 10 分钟';
@override
String get planFreeTag => '免费版';
@override
String get watchAd => '看广告开始使用';
@override
String get adUnlocked => '已解锁 · 今日可用';
@override
String get quotaLeftLabel => '剩余';
@override
String get quotaUsedUp => '今日免费时长已用完';
@override
String get watchAdMore => '看广告加时';
@override
String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员';
@override
String get adPlaying => '广告播放中…';
@override
String adRewarded(int minutes) => '已加 $minutes 分钟';
@override
String get adFailed => '加时失败,请重试';
@override
String get quotaDesktopTitle => '今日免费时长已用完';
@override
String get quotaDesktopBody => '前往移动端 App 看广告加时,或升级会员畅享无限时长。';
@override
String get gotIt => '知道了';
@override
String get chooseNode => '选择节点';
@@ -73,6 +95,8 @@ class StringsZh extends AppText {
@override
String get nodeUnhealthyError => '当前节点异常,请重连或更换节点';
@override
String get nodeReconnecting => '网络波动,正在重连…';
@override
String get smartSub => '根据当前网络环境,自动选择最优节点';
@override
String get recommended => '推荐';
@@ -210,6 +234,20 @@ class StringsZh extends AppText {
String get killSwitchSub => '断线时阻断网络,防止泄露';
@override
String get checkUpdate => '检查更新';
@override
String get webUserCenter => '用户中心(网页)';
@override
String updateAvailableTitle(String version) => '发现新版本 v$version';
@override
String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。';
@override
String get updateLater => '稍后';
@override
String get updateDownload => '下载更新';
@override
String get updateUpToDate => '已是最新版本';
@override
String get updateCheckFailed => '检查更新失败,请稍后重试';
@override
String get choosePlan => '选择套餐';
+7 -1
View File
@@ -12,6 +12,7 @@ class Me {
this.devicesUsed = 0,
this.devicesMax = 1,
this.quotaTodayMin,
this.quotaCapMin,
this.dataTodayGb = 0,
this.weeklyGb = const [],
this.totpEnabled = false,
@@ -29,9 +30,13 @@ class Me {
final int devicesMax;
/// 今日**剩余**额度(分钟);null = 不限(pro/team)。
/// 后端已算好 = 套餐每日上限 今日已用。
/// 后端已算好 = 当日额度 今日已用(账户共享,非每设备)
final int? quotaTodayMin;
/// 今日**总额度**(分钟)= 套餐每日上限 + 看广告累加分钟;null = 不限。
/// 供进度条分母使用(剩余 / 总额度)。
final int? quotaCapMin;
/// 今日已用流量(GB)。
final double dataTodayGb;
@@ -51,6 +56,7 @@ class Me {
devicesUsed: (m['devices_used'] as num?)?.toInt() ?? 0,
devicesMax: (m['devices_max'] as num?)?.toInt() ?? 1,
quotaTodayMin: (m['quota_today_min'] as num?)?.toInt(),
quotaCapMin: (m['quota_cap_min'] as num?)?.toInt(),
dataTodayGb: (m['data_today_gb'] as num?)?.toDouble() ?? 0,
weeklyGb: ((m['weekly_gb'] as List<dynamic>?) ?? const [])
.map((e) => (e as num).toDouble())
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -1,6 +1,6 @@
// pangolin_tokens.gen.dart
// AUTO-GENERATED — 勿手改。
// 源: design/colors_and_type.css
// 源: design/prototype/tokens.css
// 生成器: design/codegen/gen_flutter_tokens.mjs
//
// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow
@@ -9,7 +9,7 @@
import 'package:flutter/material.dart';
/// ── Primitive color ramps ───────────────────────────────────────────
/// Auto-generated from design/colors_and_type.css :root color ramps.
/// Auto-generated from design/prototype/tokens.css :root color ramps.
class PangolinColors {
PangolinColors._();
+31 -25
View File
@@ -99,7 +99,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(
@@ -375,31 +375,37 @@ class _LangSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = context.pangolin;
Widget seg(AppLang v, String label) {
final active = lang == v;
return GestureDetector(
onTap: () => onChange(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
decoration: BoxDecoration(
color: active ? c.accent : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
// 6 种语言用下拉(段控横排会挤)。
return PopupMenuButton<AppLang>(
initialValue: lang,
onSelected: onChange,
color: c.surface,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
itemBuilder: (_) => [
for (final l in AppLang.values)
PopupMenuItem<AppLang>(
value: l,
height: 42,
child: Text(l.nativeLabel,
style: PangolinText.caption.copyWith(
color: l == lang ? c.accent : c.fg1,
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
fontSize: 13)),
),
child: Text(label,
style: PangolinText.caption.copyWith(
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
seg(AppLang.zh, '中文'),
const SizedBox(width: 2),
seg(AppLang.en, 'EN'),
]),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
decoration:
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(lang.nativeLabel,
style: PangolinText.caption
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)),
const SizedBox(width: 4),
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
]),
),
);
}
}
+24 -2
View File
@@ -11,6 +11,7 @@ import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../state/quota_provider.dart';
import '../widgets/ad_reward_dialog.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/connect_button.dart';
import '../widgets/country_code.dart';
@@ -52,11 +53,18 @@ class ConnectPage extends ConsumerWidget {
VpnPhase.on => t.capOn,
};
// 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。
final isDesktop = context.formFactor == FormFactor.desktop;
final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off);
void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop);
final button = ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
);
@@ -75,7 +83,13 @@ class ConnectPage extends ConsumerWidget {
final infoChildren = <Widget>[
if (isFree)
QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue),
_CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes),
];
@@ -100,6 +114,8 @@ class ConnectPage extends ConsumerWidget {
offLabel: t.connectNow,
secureLabel: t.secure,
size: 176,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
),
const SizedBox(height: 24),
@@ -120,7 +136,13 @@ class ConnectPage extends ConsumerWidget {
const SizedBox(height: 24),
SizedBox(
width: 340,
child: QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
child: QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
),
],
if (conn.phase == VpnPhase.on) ...[
+1 -1
View File
@@ -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(
+54 -22
View File
@@ -9,9 +9,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/web_launch.dart';
import '../state/app_providers.dart';
import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
final info = await ref.read(updateProvider.notifier).forceCheck();
if (!context.mounted) return;
if (info == null) {
showPangolinToast(context, t.updateCheckFailed);
return;
}
if (!info.hasUpdate) {
showPangolinToast(context, t.updateUpToDate);
return;
}
// 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。
// 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。
ref.read(dismissedUpdateVersionProvider.notifier).state = null;
showPangolinToast(context, t.updateAvailableTitle(info.latestVersion));
}
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@@ -52,14 +73,15 @@ 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,
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
),
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
]),
]),
@@ -127,27 +149,37 @@ class _LangSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = context.pangolin;
Widget seg(AppLang v, String label) {
final on = lang == v;
return GestureDetector(
onTap: () => onPick(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: on ? c.accent : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
// 6 种语言用下拉(段控横排会挤)。当前语言以本地名 + 下拉箭头展示。
return PopupMenuButton<AppLang>(
initialValue: lang,
onSelected: onPick,
color: c.surface,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
itemBuilder: (_) => [
for (final l in AppLang.values)
PopupMenuItem<AppLang>(
value: l,
height: 42,
child: Text(l.nativeLabel,
style: PangolinText.caption.copyWith(
color: l == lang ? c.accent : c.fg1,
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
fontSize: 13)),
),
child: Text(label,
style: PangolinText.caption.copyWith(
color: on ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12)),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [seg(AppLang.zh, '中文'), seg(AppLang.en, 'EN')]),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration:
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(lang.nativeLabel,
style: PangolinText.caption
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12)),
const SizedBox(width: 4),
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
]),
),
);
}
}
+19 -3
View File
@@ -99,7 +99,23 @@ class AccountApi {
Future<RedeemResult> redeem(String code) async =>
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
/// POST /v1/ads/unlock — 看广告解锁今日免费额度
Future<void> adUnlock({required String deviceId, required String adToken}) =>
_c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
/// POST /v1/ads/unlock — 看广告加时(累加式)。返回本次加时分钟与最新剩余分钟
Future<AdUnlockResult> adUnlock({required String deviceId, required String adToken}) async {
final body = await _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
return AdUnlockResult(
grantedMinutes: (body['granted_minutes'] as num?)?.toInt() ?? 0,
minutesRemaining: (body['minutes_remaining'] as num?)?.toInt() ?? 0,
);
}
}
/// 看广告加时结果(POST /v1/ads/unlock 响应)。
class AdUnlockResult {
const AdUnlockResult({required this.grantedMinutes, required this.minutesRemaining});
/// 本次广告实际加时分钟(已达每日封顶时为 0)。
final int grantedMinutes;
/// 加时后账户当日剩余分钟(全账户共享)。
final int minutesRemaining;
}
+11 -2
View File
@@ -2,8 +2,17 @@
//
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
// 由 --dart-define=PANGOLIN_API_URL 注入。
// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://103.119.13.48:8080',
defaultValue: 'https://api.yanmeiai.com',
);
/// 用户中心(网页)基址(单源)。已从独立子域 app.yanmeiai.com 迁到主站子路径
/// pangolin.yanmeiai.com/user/(旧子域已停用)。末尾不带斜杠;调用方自行拼 path。
/// 本地联调可 --dart-define=PANGOLIN_USERCENTER_URL=http://127.0.0.1:3000 覆盖。
const String kWebUserCenterBaseUrl = String.fromEnvironment(
'PANGOLIN_USERCENTER_URL',
defaultValue: 'https://pangolin.yanmeiai.com/user',
);
+22
View File
@@ -121,5 +121,27 @@ class ConnectApi {
return response.body;
}
/// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。
///
/// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉——
/// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。
Future<void> disconnect({
required String nodeId,
required String deviceId,
}) async {
try {
await _client
.post(
Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 5));
} catch (_) {/* best-effort */}
}
void dispose() => _client.close();
}
+27
View File
@@ -0,0 +1,27 @@
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
//
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../state/account_providers.dart';
import 'api_config.dart';
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
try {
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
final ticket = resp['ticket'] as String?;
if (ticket != null && ticket.isNotEmpty) {
target = Uri.parse(
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
}
} catch (_) {
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
}
await launchUrl(target, mode: LaunchMode.externalApplication);
}
+30 -1
View File
@@ -8,6 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/responsive/form_factor.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/update_provider.dart';
import '../widgets/update_dialog.dart';
import 'desktop_shell.dart';
import 'mobile_shell.dart';
import 'tablet_shell.dart';
@@ -18,11 +21,37 @@ class HomeShell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
final info = next.valueOrNull;
if (info != null && info.hasUpdate && info.forceUpdate) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
showUpdateDialog(context, ref.read(appTextProvider), info);
}
});
}
});
final info = ref.watch(updateProvider).valueOrNull;
final dismissedVer = ref.watch(dismissedUpdateVersionProvider);
final showBanner = info != null &&
info.hasUpdate &&
!info.forceUpdate &&
dismissedVer != info.latestVersion;
final Widget body = switch (context.formFactor) {
FormFactor.desktop => const DesktopShell(),
FormFactor.tablet => const TabletShell(),
FormFactor.mobile => const MobileShell(),
};
return Scaffold(backgroundColor: c.bg, body: body);
return Scaffold(
backgroundColor: c.bg,
body: showBanner
? Column(children: [UpdateBanner(info: info), Expanded(child: body)])
: body,
);
}
}
+9 -1
View File
@@ -40,11 +40,19 @@ class MeNotifier extends AsyncNotifier<Me> {
return ref.read(accountApiProvider).me();
}
/// 手动刷新(如连接/兑换后)。
/// 手动刷新(如兑换后 / 下拉刷新)。会短暂进 loading 态(触发整页转圈)。
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
}
/// 静默刷新:不进 loading 态、成功才替换。供连接期/断开后刷新「今日剩余」用,
/// 避免 refresh() 的 AsyncLoading 让连接页整页闪 spinner(meLoadingProvider)。
Future<void> silentRefresh() async {
if (!ref.read(authProvider).isLoggedIn) return;
final next = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
if (next is AsyncData<Me>) state = next;
}
}
final meProvider = AsyncNotifierProvider<MeNotifier, Me>(MeNotifier.new);
+62 -6
View File
@@ -1,21 +1,77 @@
// 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';
import '../l10n/strings_es.dart';
import '../l10n/strings_ja.dart';
import '../l10n/strings_ko.dart';
import '../l10n/strings_ru.dart';
import '../l10n/strings_zh.dart';
import 'account_providers.dart';
import 'auth_provider.dart';
/// 当前语言(单显)。设置/账户页段控切换。
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
AppText appTextFor(AppLang lang) {
switch (lang) {
case AppLang.zh:
return const StringsZh();
case AppLang.en:
return const StringsEn();
case AppLang.ja:
return const StringsJa();
case AppLang.ko:
return const StringsKo();
case AppLang.ru:
return const StringsRu();
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) {
final lang = ref.watch(localeProvider);
return lang == AppLang.zh ? const StringsZh() : const StringsEn();
});
final appTextProvider = Provider<AppText>((ref) => appTextFor(ref.watch(localeProvider)));
/// 主题模式。默认跟随系统;设置页可显式切深色。
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
+142 -20
View File
@@ -19,9 +19,11 @@ import '../services/api_config.dart';
import '../services/auth_api.dart';
import '../services/connect_api.dart';
import '../services/device_identity.dart';
import 'account_providers.dart';
import 'app_providers.dart';
import 'auth_provider.dart';
import 'nodes_provider.dart';
import 'quota_provider.dart';
import 'settings_provider.dart';
// 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。
@@ -35,7 +37,12 @@ enum VpnPhase { off, connecting, on }
// ── 连接状态快照 ──────────────────────────────────────────────────
class ConnectionState {
const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error});
const ConnectionState({
required this.phase,
this.elapsed = Duration.zero,
this.error,
this.freeCountdown,
});
final VpnPhase phase;
final Duration elapsed;
@@ -43,18 +50,27 @@ class ConnectionState {
/// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
final String? error;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
/// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。
/// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。
final Duration? freeCountdown;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) =>
ConnectionState(
phase: phase ?? this.phase,
elapsed: elapsed ?? this.elapsed,
freeCountdown: freeCountdown ?? this.freeCountdown,
);
@override
bool operator ==(Object other) =>
other is ConnectionState &&
other.phase == phase &&
other.elapsed == elapsed &&
other.error == error;
other.error == error &&
other.freeCountdown == freeCountdown;
@override
int get hashCode => Object.hash(phase, elapsed, error);
int get hashCode => Object.hash(phase, elapsed, error, freeCountdown);
}
// ── 连通看门狗 ─────────────────────────────────────────────────────
@@ -76,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45);
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
const _kStatsLive = Duration(seconds: 10);
// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18
const _kMaxAutoReconnect = 3;
// ── 状态机 ───────────────────────────────────────────────────────
@@ -92,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
unawaited(_disconnect());
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
unawaited(_disconnect(revokeCredential: true));
}
});
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
@@ -140,6 +159,12 @@ class ConnectionController extends StateNotifier<ConnectionState> {
ConnectApi? _api;
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
Node? _connectedNode;
// 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或
// 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18
int _autoReconnectAttempts = 0;
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec 已用秒」倒计时,
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
int? _freeRemainingSec;
// ── 公有 API ───────────────────────────────────────────────────
@@ -147,10 +172,11 @@ class ConnectionController extends StateNotifier<ConnectionState> {
void toggle() {
switch (state.phase) {
case VpnPhase.off:
_autoReconnectAttempts = 0; // 用户主动连接:重置弱网自动重连额度(#18)
_connect();
case VpnPhase.on:
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
_disconnect();
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
case VpnPhase.connecting:
break; // 握手进行中,不响应
}
@@ -170,10 +196,15 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_userDisconnect = false;
_lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位)
_lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位)
// 免费版:锁定本次连接可用的剩余额度(账户共享,权威取自 me)。会员为 null 不倒计时。
_freeRemainingSec = _ref.read(isFreePlanProvider)
? _ref.read(quotaProvider).remainingMinutes * 60
: null;
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}');
@@ -182,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;
@@ -202,13 +233,19 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (mounted) state = const ConnectionState(phase: VpnPhase.off);
return;
}
// 免费额度已用完(服务端兜底):本地置耗尽 → 按钮灰化、点击弹广告/升级。回 off。
if (e.code == 'QUOTA_EXHAUSTED') {
_ref.read(quotaProvider.notifier).markExhausted();
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
return;
}
// 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
} catch (e) {
if (mounted) {
state = ConnectionState(
phase: VpnPhase.off,
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
error: lang.connectFailed,
);
}
}
@@ -242,7 +279,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
}
Future<void> _disconnect() async {
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
Future<void> _disconnect({bool revokeCredential = false}) async {
if (revokeCredential) {
final api = _api;
final node = _connectedNode;
if (api != null && node != null && node.uuid.isNotEmpty) {
unawaited(() async {
try {
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
} catch (_) {/* best-effort */}
}());
}
}
_stopElapsed();
_stopWatchdog();
try {
@@ -250,12 +303,26 @@ class ConnectionController extends StateNotifier<ConnectionState> {
} catch (_) {}
// 携带 _offNotice(节点异常/null);与 kernel off 读同一字段,不互相覆盖。
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
_refreshQuota(); // 会话结束 → 刷新「今日剩余」反映本次消耗
}
// 静默刷新 /me(不闪整页 spinner),让免费额度「今日剩余」跨会话/跨设备更新。
void _refreshQuota() {
if (_ref.read(authProvider).isLoggedIn && _ref.read(isFreePlanProvider)) {
unawaited(_ref.read(meProvider.notifier).silentRefresh());
}
}
void _onKernelStatus(VpnStatus s) {
if (!mounted) return;
switch (s) {
case VpnStatus.on:
// 免费版:若本次「on」不是经 _connect 而来(如 Android 常驻隧道、app 重启后
// 自动同步到已在跑的隧道),_freeRemainingSec 还是 null → 倒计时不启。这里兜底
// 按当前额度补锁定,保证连接页显示的是倒计时(会变)而非静态「今日剩余」。
if (_freeRemainingSec == null && _ref.read(isFreePlanProvider)) {
_freeRemainingSec = _ref.read(quotaProvider).remainingMinutes * 60;
}
state = state.copyWith(phase: VpnPhase.on);
_startElapsed();
_startWatchdog();
@@ -269,15 +336,20 @@ class ConnectionController extends StateNotifier<ConnectionState> {
// 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→
// connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。
final wasConnected = state.phase == VpnPhase.on;
_stopElapsed();
_stopWatchdog();
if (!_userDisconnect && wasConnected && _offNotice == null) {
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
// 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连
// **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才
// 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18
logLine('Watchdog', 'unexpected kernel ${s.name} after connected → node interrupted');
unawaited(_ref.read(nodesProvider.notifier).refresh());
_userDisconnect = false;
unawaited(_handleUnexpectedOff());
return;
}
state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
_userDisconnect = false;
_stopElapsed();
_stopWatchdog();
_refreshQuota(); // 内核掉线也算会话结束 → 刷新「今日剩余」
}
}
@@ -300,6 +372,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (ds.isEmpty) return;
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
_lastUrltestOk = now;
_autoReconnectAttempts = 0; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18
final best = ds.reduce((a, b) => a < b ? a : b);
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
}
@@ -370,12 +443,38 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
return;
}
// 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换
// 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。
// 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连
// 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18
if (await _tryAutoReconnectCurrent()) return;
// 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。
_offNotice = t.nodeUnhealthyError;
await _disconnect();
}
/// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。
/// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18
/// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。
Future<bool> _tryAutoReconnectCurrent() async {
if (_autoReconnectAttempts >= _kMaxAutoReconnect) return false;
_autoReconnectAttempts++;
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
logLine('Watchdog', 'auto-reconnect current node ${node.code} (attempt $_autoReconnectAttempts/$_kMaxAutoReconnect)');
await _disconnect();
await _connect();
// 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。
if (mounted && state.phase != VpnPhase.off) {
state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).nodeReconnecting);
}
return true;
}
/// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18
Future<void> _handleUnexpectedOff() async {
if (await _tryAutoReconnectCurrent()) return;
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
await _disconnect();
}
/// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。
String? _pickAlternativeCode(String excludeCode) {
final nodes = (_ref.read(nodesProvider).valueOrNull ?? const [])
@@ -397,18 +496,41 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed());
}
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准);免费版顺带推算
/// 倒计时,归零即自动切断。倒计时用墙上时钟,后台/锁屏漏跳也会在回前台补上、准时切。
void _refreshElapsed() {
final at = _connectedAt;
if (mounted && state.phase == VpnPhase.on && at != null) {
state = state.copyWith(elapsed: _now().difference(at));
if (!mounted || state.phase != VpnPhase.on || at == null) return;
final elapsed = _now().difference(at);
Duration? countdown;
final capSec = _freeRemainingSec;
if (capSec != null) {
final leftSec = capSec - elapsed.inSeconds;
if (leftSec <= 0) {
unawaited(_onFreeQuotaExhausted());
return;
}
countdown = Duration(seconds: leftSec);
}
state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown);
}
/// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。
Future<void> _onFreeQuotaExhausted() async {
_freeRemainingSec = null;
_userDisconnect = true; // 视为主动断开,不触发「节点异常」
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
_ref.read(quotaProvider.notifier).markExhausted();
logLine('Quota', 'free daily minutes used up → auto disconnect');
await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
}
void _stopElapsed() {
_elapsed?.cancel();
_elapsed = null;
_connectedAt = null;
_freeRemainingSec = null;
}
@override
+43 -18
View File
@@ -1,11 +1,16 @@
// quota_provider.dart — 免费版每日额度状态(Riverpod)
//
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端
// 总额度取自 plans 的 free.daily_minutes,今日剩余取自 me.quota_today_min
// (后端已算好 = 上限 今日已用)。adUnlocked 为本地会话态(看广告需 ad SDK,
// 尚未接入,保留本地乐观置位)。
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,且**全账户共享**
// (非每设备)。总额度取自 me.quota_cap_min(= 套餐每日上限 + 看广告累加分钟),
// 今日剩余取自 me.quota_today_min(后端已算好 = 额度 今日已用)。
//
// 看广告加时(累加式):watchAd() 调 /v1/ads/unlock,服务端校验后 +N 分钟并回传最新
// 剩余,客户端随即刷新 me 让额度权威同步。占位广告 SDK 阶段用客户端生成的 ad_token,
// 服务端 DevVerifier 放行(nonce 仍防重放)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../services/device_identity.dart';
import 'account_providers.dart';
/// 免费额度快照。
@@ -13,18 +18,14 @@ class FreeQuotaState {
const FreeQuotaState({
this.totalMinutes = 10,
this.remainingMinutes = 10,
this.adUnlocked = false,
});
/// 日总额度(分钟)。§7:免费版每日 10 分钟。
/// 日总额度(分钟)= 套餐每日上限 + 看广告累加。§7:免费版基础每日 10 分钟。
final int totalMinutes;
/// 今日剩余分钟(展示值,权威以服务端为准)。
/// 今日剩余分钟(权威以服务端为准;连接期倒计时由连接状态机本地推算)。
final int remainingMinutes;
/// 今日是否已观看激励视频解锁。
final bool adUnlocked;
/// 进度(0–1),用于进度条宽度。
double get progress =>
totalMinutes == 0 ? 0 : (remainingMinutes / totalMinutes).clamp(0.0, 1.0);
@@ -32,11 +33,12 @@ class FreeQuotaState {
/// 是否进入低额度警示(≤3 分钟切 warning 色)。
bool get isLow => remainingMinutes <= 3;
FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes, bool? adUnlocked}) =>
FreeQuotaState(
/// 今日额度是否已耗尽(剩余 0):连接按钮据此灰化,点击弹广告/升级。
bool get isExhausted => remainingMinutes <= 0;
FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes}) => FreeQuotaState(
totalMinutes: totalMinutes ?? this.totalMinutes,
remainingMinutes: remainingMinutes ?? this.remainingMinutes,
adUnlocked: adUnlocked ?? this.adUnlocked,
);
}
@@ -53,18 +55,41 @@ class QuotaController extends StateNotifier<FreeQuotaState> {
void _sync() {
final me = _ref.read(meProvider).valueOrNull;
final plans = _ref.read(plansProvider).valueOrNull;
var total = 10; // §7 默认免费 10 分钟,plans 就绪后以其为准
var base = 10; // §7 默认免费基础 10 分钟,plans 就绪后以其为准
if (plans != null) {
for (final p in plans) {
if (p.code == 'free' && p.dailyMinutes != null) total = p.dailyMinutes!;
if (p.code == 'free' && p.dailyMinutes != null) base = p.dailyMinutes!;
}
}
// 总额度优先取服务端 quota_cap_min(含看广告加时);缺省回退基础额度。
final total = me?.quotaCapMin ?? base;
final remaining = (me?.quotaTodayMin ?? total).clamp(0, total);
state = state.copyWith(totalMinutes: total, remainingMinutes: remaining);
state = FreeQuotaState(totalMinutes: total, remainingMinutes: remaining);
}
/// 观看激励视频后解锁今日使用(本地乐观;真实 ad 校验待 ad SDK 接入)
void watchAd() => state = state.copyWith(adUnlocked: true);
/// 连接期倒计时归零 → 本地立即置耗尽(按钮随即灰化);登录态下再静默拉 me 让服务端权威同步
void markExhausted() {
state = state.copyWith(remainingMinutes: 0);
_ref.read(meProvider.notifier).silentRefresh();
}
/// 看广告加时:调 /v1/ads/unlock 累加分钟,成功后刷新 me 拿最新额度。
/// 返回本次加时分钟(null = 失败)。占位阶段用客户端生成的 ad_token。
Future<int?> watchAd() async {
try {
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
final res = await _ref.read(accountApiProvider).adUnlock(
deviceId: deviceId,
adToken: const Uuid().v4(), // 占位 ad_token(DevVerifier 放行)
);
// 乐观置位剩余,再静默拉 me 校准(账户共享,以服务端为准;不闪整页 spinner)。
state = state.copyWith(remainingMinutes: res.minutesRemaining);
await _ref.read(meProvider.notifier).silentRefresh();
return res.grantedMinutes;
} catch (_) {
return null;
}
}
}
final quotaProvider = StateNotifierProvider<QuotaController, FreeQuotaState>(
+148
View File
@@ -0,0 +1,148 @@
// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。
//
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
//
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import '../services/api_config.dart';
/// 启动后首次检查的延迟(避开登录/首屏竞争)。
const _kInitialDelay = Duration(seconds: 3);
/// 轮询间隔。
const _kPollInterval = Duration(hours: 1);
/// 单次检查网络超时。
const _kCheckTimeout = Duration(seconds: 8);
/// 一次更新检查的结果。
class AppUpdateInfo {
const AppUpdateInfo({
required this.latestVersion,
required this.buildNumber,
required this.forceUpdate,
required this.releaseNotes,
required this.downloadUrls,
required this.hasUpdate,
});
final String latestVersion;
final int buildNumber;
final bool forceUpdate;
final String releaseNotes;
final Map<String, String> downloadUrls;
final bool hasUpdate;
}
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
Timer? _timer;
Timer? _initialTimer;
@override
Future<AppUpdateInfo?> build() async {
// 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非
// Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose
// 时会悬挂 → widget 测试报 pending timer、生产留资源)。
ref.onDispose(() {
_initialTimer?.cancel();
_timer?.cancel();
});
final ready = Completer<void>();
_initialTimer = Timer(_kInitialDelay, ready.complete);
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
final first = await _check();
_timer = Timer.periodic(_kPollInterval, (_) async {
state = AsyncValue.data(await _check());
});
return first;
}
/// 设置页「检查更新」手动触发:立即查一次并回结果。
Future<AppUpdateInfo?> forceCheck() async {
final info = await _check();
state = AsyncValue.data(info);
return info;
}
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
Future<AppUpdateInfo?> _check() async {
try {
final resp = await http
.get(Uri.parse('$kApiBaseUrl/version'))
.timeout(_kCheckTimeout);
if (resp.statusCode != 200) return null;
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final latestVersion = data['version'] as String? ?? '0.0.0';
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
final forceUpdate = data['force_update'] as bool? ?? false;
final releaseNotes = data['release_notes'] as String? ?? '';
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
final pkg = await PackageInfo.fromPlatform();
final hasUpdate = _isNewer(latestVersion, pkg.version);
return AppUpdateInfo(
latestVersion: latestVersion,
buildNumber: buildNumber,
forceUpdate: forceUpdate,
releaseNotes: releaseNotes,
downloadUrls: downloadUrls,
hasUpdate: hasUpdate,
);
} catch (_) {
return null;
}
}
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
bool _isNewer(String latest, String current) {
final l = _parse(latest);
final c = _parse(current);
for (var i = 0; i < 3; i++) {
if (l[i] > c[i]) return true;
if (l[i] < c[i]) return false;
}
return false;
}
List<int> _parse(String v) {
final parts = v.split('.').map((s) {
final m = RegExp(r'^\d+').firstMatch(s);
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
while (parts.length < 3) {
parts.add(0);
}
return parts;
}
}
final updateProvider =
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
/// 供 shell 顶部 banner 的显隐判断。
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
if (Platform.isWindows) return downloadUrls['windows'];
if (Platform.isIOS) return downloadUrls['ios'];
if (Platform.isAndroid) return downloadUrls['android'];
return downloadUrls['web'];
}
+4 -9
View File
@@ -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(
+156
View File
@@ -0,0 +1,156 @@
// ad_reward_dialog.dart — 免费额度耗尽后的「加时」入口(占位广告流程 + 桌面升级提示)
//
// 移动端:弹占位广告(「广告播放中…」→ 3s 假播放 → 调 /v1/ads/unlock 加时 → 显示奖励)。
// 桌面端(Windows/macOS):免费版硬 10 分钟/天不可延,无广告——弹「去移动端看广告或升级」。
// 接真广告 SDK(AdMob 激励视频)时,只需把 onWatch 换成「真播完再回调」即可,UI 不变。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/quota_provider.dart';
import 'pangolin_icons.dart';
/// 展示加时流程。isDesktop=true 走升级提示(无广告),否则走占位广告加时。
Future<void> showQuotaAdFlow(BuildContext context, WidgetRef ref, {required bool isDesktop}) async {
final t = ref.read(appTextProvider);
if (isDesktop) {
await showDialog<void>(context: context, builder: (_) => _DesktopUpgradeDialog(t: t));
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _PlaceholderAdDialog(
t: t,
onWatch: () => ref.read(quotaProvider.notifier).watchAd(),
),
);
}
/// 桌面版:免费硬 10 分钟不可延,提示去移动端加时或升级会员。
class _DesktopUpgradeDialog extends StatelessWidget {
const _DesktopUpgradeDialog({required this.t});
final AppText t;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
title: Text(t.quotaDesktopTitle,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
content: Text(t.quotaDesktopBody, style: PangolinText.sm.copyWith(color: c.fg2)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
);
}
}
enum _AdPhase { playing, done, failed }
/// 移动端占位激励广告:播放中 → 加时成功/失败。成功后短暂展示奖励再自动关闭。
class _PlaceholderAdDialog extends StatefulWidget {
const _PlaceholderAdDialog({required this.t, required this.onWatch});
final AppText t;
final Future<int?> Function() onWatch;
@override
State<_PlaceholderAdDialog> createState() => _PlaceholderAdDialogState();
}
class _PlaceholderAdDialogState extends State<_PlaceholderAdDialog> {
_AdPhase _phase = _AdPhase.playing;
int _granted = 0;
Timer? _closeTimer;
@override
void initState() {
super.initState();
_run();
}
Future<void> _run() async {
// 占位「播放」3s(接真 SDK 后由激励视频完成回调替代)。
await Future<void>.delayed(const Duration(seconds: 3));
if (!mounted) return;
final granted = await widget.onWatch();
if (!mounted) return;
setState(() {
if (granted != null && granted > 0) {
_phase = _AdPhase.done;
_granted = granted;
} else {
_phase = _AdPhase.failed;
}
});
if (_phase == _AdPhase.done) {
_closeTimer = Timer(const Duration(milliseconds: 1300), () {
if (mounted) Navigator.of(context).pop();
});
}
}
@override
void dispose() {
_closeTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
Widget body;
switch (_phase) {
case _AdPhase.playing:
body = Column(mainAxisSize: MainAxisSize.min, children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(color: c.accent, strokeWidth: 3),
),
const SizedBox(height: 16),
Text(t.adPlaying, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
]);
case _AdPhase.done:
body = Column(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.checkCircle, size: 40, color: c.success),
const SizedBox(height: 14),
Text(t.adRewarded(_granted),
style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w700)),
]);
case _AdPhase.failed:
body = Column(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.alertTriangle, size: 36, color: c.danger),
const SizedBox(height: 14),
Text(t.adFailed, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
const SizedBox(height: 14),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
]);
}
return Dialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.lg)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 28),
child: AnimatedSize(
duration: PangolinMotion.base,
child: body,
),
),
);
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ class _MenuCard extends StatelessWidget {
child: Material(
color: c.surface,
elevation: 6,
shadowColor: Colors.black26,
shadowColor: PangolinColors.sand950.withValues(alpha: 0.24), // 暖近黑阴影(§5;非冷黑 Colors.black26)
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.md),
+11 -11
View File
@@ -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)),
),
]),
+28 -12
View File
@@ -24,11 +24,19 @@ class ConnectButton extends StatefulWidget {
required this.secureLabel,
this.elapsed = Duration.zero,
this.size = 208,
this.enabled = true,
this.onDisabledTap,
});
final VpnPhase phase;
final VoidCallback onTap;
/// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。
final bool enabled;
/// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。
final VoidCallback? onDisabledTap;
/// off 态圆内文字(如「点击连接」/「CONNECT」)。
final String offLabel;
@@ -55,18 +63,26 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
Widget build(BuildContext context) {
final c = context.pangolin;
final s = widget.phase;
// 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。
final disabled = !widget.enabled && s == VpnPhase.off;
final Color fill = switch (s) {
VpnPhase.off => c.bgSubtle,
VpnPhase.connecting => c.accent,
VpnPhase.on => c.success,
};
final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white;
final IconData icon = switch (s) {
VpnPhase.off => PangolinIcons.power,
VpnPhase.connecting => PangolinIcons.loader,
VpnPhase.on => PangolinIcons.shieldCheck,
};
final Color fill = disabled
? c.bgSubtle
: switch (s) {
VpnPhase.off => c.bgSubtle,
VpnPhase.connecting => c.accent,
VpnPhase.on => c.success,
};
final Color fg = disabled
? c.fg3
: (s == VpnPhase.off ? c.accent : PangolinColors.white);
final IconData icon = disabled
? PangolinIcons.lock
: switch (s) {
VpnPhase.off => PangolinIcons.power,
VpnPhase.connecting => PangolinIcons.loader,
VpnPhase.on => PangolinIcons.shieldCheck,
};
// off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。
final List<BoxShadow> glow = s == VpnPhase.off
@@ -84,7 +100,7 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
button: true,
label: widget.offLabel,
child: GestureDetector(
onTap: widget.onTap,
onTap: disabled ? widget.onDisabledTap : widget.onTap,
child: AnimatedContainer(
duration: PangolinMotion.slow,
curve: PangolinMotion.easeOut,
+79 -33
View File
@@ -1,7 +1,10 @@
// quota_card.dart — 免费版每日额度卡(纯展示)
// quota_card.dart — 免费版每日额度卡
//
// 今日剩余分钟 + 进度条(≤3 分钟切 warning 色)+「看广告开始使用」;
// 解锁后变绿「已解锁 · 今日可用」。状态由 quota_provider 注入,本地仅展示
// 三态展示(额度全账户共享):
// ① 连接中(countdown != null):显示剩余倒计时 mm:ss + 进度条随之收缩
// ② 未连接·有余额:显示今日剩余分钟 + 进度条;移动端附「看广告加时」。
// ③ 未连接·已耗尽:显示「今日已用完」+ 移动端「看广告加时」/ 桌面「升级会员」。
// 状态由 quota_provider(剩余)+ connection_provider(倒计时)注入,本地仅展示。
import 'package:flutter/material.dart';
import '../l10n/app_text.dart';
@@ -10,16 +13,55 @@ import '../state/quota_provider.dart';
import 'pangolin_icons.dart';
class QuotaCard extends StatelessWidget {
const QuotaCard({super.key, required this.quota, required this.t, required this.onWatchAd});
const QuotaCard({
super.key,
required this.quota,
required this.t,
required this.onWatchAd,
this.countdown,
this.isDesktop = false,
});
final FreeQuotaState quota;
final AppText t;
/// 额度耗尽时的加时入口(移动端占位广告 / 桌面升级提示)。
final VoidCallback onWatchAd;
/// 连接期剩余倒计时;null = 未连接(展示今日剩余分钟)。
final Duration? countdown;
/// 桌面端(Windows/macOS):免费硬 10 分钟不可延,不显示看广告按钮。
final bool isDesktop;
static String _mmss(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final barColor = quota.isLow ? c.warning : c.accent;
final connected = countdown != null;
final exhausted = quota.isExhausted && !connected;
// 进度与主值:连接期用倒计时,未连接用剩余分钟。
final double progress;
final String label;
final String value;
if (connected) {
final totalSec = quota.totalMinutes * 60;
progress = totalSec == 0 ? 0 : (countdown!.inSeconds / totalSec).clamp(0.0, 1.0);
label = t.quotaLeftLabel;
value = _mmss(countdown!);
} else {
progress = quota.progress;
label = exhausted ? t.quotaUsedUp : t.quotaToday;
value = exhausted ? '' : '${quota.remainingMinutes} ${t.minutes}';
}
final low = connected ? countdown!.inMinutes < 3 : quota.isLow;
final barColor = exhausted ? c.danger : (low ? c.warning : c.accent);
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
@@ -33,23 +75,32 @@ class QuotaCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
Icon(PangolinIcons.clock, size: 15, color: c.accent),
Icon(PangolinIcons.clock, size: 15, color: exhausted ? c.danger : c.accent),
const SizedBox(width: 7),
Text(t.quotaToday,
// 标签固定、绝不省略(此前用 Flexible 让「今日剩余」被挤成「今日…」)。
Text(label,
style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(width: 7),
Text('${quota.remainingMinutes} ${t.minutes}',
style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)),
if (value.isNotEmpty) ...[
const SizedBox(width: 7),
// 数值可作为最后收缩项(极端窄屏/三位数分钟才会省略)。
Flexible(
child: Text(value,
overflow: TextOverflow.ellipsis,
style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)),
),
],
const SizedBox(width: 8),
const Spacer(),
// 短标签「免费版」——每日额度由卡片本身表达,不再挤进右上角。
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(t.quotaFree,
child: Text(t.planFreeTag,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 10.5)),
),
]),
const SizedBox(height: 9),
// 进度条:剩余比例;≤3 分钟 warning(满宽轨道 + 比例填充)
// 进度条:剩余比例;≤3 分钟 warning、耗尽 danger(满宽轨道 + 比例填充)
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: SizedBox(
@@ -58,7 +109,7 @@ class QuotaCard extends StatelessWidget {
Positioned.fill(child: ColoredBox(color: c.bgSubtle)),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: quota.progress,
widthFactor: progress,
child: AnimatedContainer(
duration: PangolinMotion.base,
curve: PangolinMotion.easeOut,
@@ -68,30 +119,25 @@ class QuotaCard extends StatelessWidget {
]),
),
),
const SizedBox(height: 11),
if (quota.adUnlocked)
SizedBox(
height: 38,
child: Center(
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.checkCircle, size: 16, color: c.success),
const SizedBox(width: 7),
Text(t.adUnlocked,
style: PangolinText.sm.copyWith(color: c.success, fontWeight: FontWeight.w700, fontSize: 13)),
]),
),
)
else
_WatchAdButton(t: t, onTap: onWatchAd),
// 加时按钮:未连接时显示。移动端「看广告加时」;桌面仅耗尽时给「升级会员」。
if (!connected && (!isDesktop || exhausted)) ...[
const SizedBox(height: 11),
_ActionButton(
label: isDesktop ? t.upgrade : t.watchAdMore,
icon: isDesktop ? PangolinIcons.zap : PangolinIcons.playCircle,
onTap: onWatchAd,
),
],
],
),
);
}
}
class _WatchAdButton extends StatelessWidget {
const _WatchAdButton({required this.t, required this.onTap});
final AppText t;
class _ActionButton extends StatelessWidget {
const _ActionButton({required this.label, required this.icon, required this.onTap});
final String label;
final IconData icon;
final VoidCallback onTap;
@override
@@ -107,9 +153,9 @@ class _WatchAdButton extends StatelessWidget {
height: 38,
child: Center(
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.playCircle, size: 16, color: c.accent),
Icon(icon, size: 16, color: c.accent),
const SizedBox(width: 7),
Text(t.watchAd,
Text(label,
style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 13)),
]),
),
+1 -1
View File
@@ -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(
+139
View File
@@ -0,0 +1,139 @@
// update_dialog.dart — 「发现新版本」更新提示弹窗。
//
// 点「下载更新」走 App 内下载安装(core/update/app_updater.dart,带进度框,不再开
// 浏览器);Android 装 APK、Windows 跑安装包、macOS 解压提示拖入、iOS 降级外链。
// force_update=true 时不可关闭(无「稍后」按钮、点遮罩/返回也关不掉)。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/update/app_updater.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/update_provider.dart';
import 'pangolin_icons.dart';
/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。
Future<void> showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) {
return showDialog<void>(
context: context,
barrierDismissible: !info.forceUpdate,
builder: (ctx) => PopScope(
canPop: !info.forceUpdate,
child: _UpdateDialog(t: t, info: info),
),
);
}
class _UpdateDialog extends StatelessWidget {
const _UpdateDialog({required this.t, required this.info});
final AppText t;
final AppUpdateInfo info;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
title: Row(children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
overflow: TextOverflow.ellipsis,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
),
),
]),
content: SingleChildScrollView(
child: Text(
info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes,
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5),
),
),
actions: [
if (!info.forceUpdate)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: () {
// 先关本弹窗,用 Navigator 自身 context(关后仍挂载)启动 App 内下载
// (它自带进度框)。
final nav = Navigator.of(context);
nav.pop();
unawaited(startInAppUpdate(nav.context, t, info));
},
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
);
}
}
/// jiu 式顶部更新横条(非强制更新用;强制更新仍走不可关闭弹窗 showUpdateDialog)。
/// 「立即更新」App 内下载安装;「×」忽略此版本(banner 隐藏,更新版本会重现)。
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key, required this.info});
final AppUpdateInfo info;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
return Material(
color: c.accentSubtle,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 8),
child: Row(children: [
Icon(PangolinIcons.zap, size: 17, color: c.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => unawaited(startInAppUpdate(context, t, info)),
style: TextButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.full)),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(t.lang.updateNow,
style: PangolinText.caption
.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)),
),
IconButton(
onPressed: () =>
ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion,
icon: Icon(Icons.close, size: 18, color: c.fg3),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
tooltip: t.updateLater,
),
]),
),
),
);
}
}