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
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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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 => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。';
|
||||
}
|
||||
@@ -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 => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.';
|
||||
}
|
||||
@@ -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 => 'Сквозное шифрование. Мы не храним журналы просмотров.';
|
||||
}
|
||||
@@ -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 => '选择套餐';
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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._();
|
||||
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user