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

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+31 -25
View File
@@ -99,7 +99,7 @@ class AccountPage extends ConsumerWidget {
_CustomRow(
icon: PangolinIcons.globe,
title: t.language,
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l),
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)),
),
Divider(height: 1, color: c.border),
_CustomRow(
@@ -375,31 +375,37 @@ class _LangSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = context.pangolin;
Widget seg(AppLang v, String label) {
final active = lang == v;
return GestureDetector(
onTap: () => onChange(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
decoration: BoxDecoration(
color: active ? c.accent : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
// 6 种语言用下拉(段控横排会挤)。
return PopupMenuButton<AppLang>(
initialValue: lang,
onSelected: onChange,
color: c.surface,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
itemBuilder: (_) => [
for (final l in AppLang.values)
PopupMenuItem<AppLang>(
value: l,
height: 42,
child: Text(l.nativeLabel,
style: PangolinText.caption.copyWith(
color: l == lang ? c.accent : c.fg1,
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
fontSize: 13)),
),
child: Text(label,
style: PangolinText.caption.copyWith(
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
seg(AppLang.zh, '中文'),
const SizedBox(width: 2),
seg(AppLang.en, 'EN'),
]),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
decoration:
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(lang.nativeLabel,
style: PangolinText.caption
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)),
const SizedBox(width: 4),
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
]),
),
);
}
}
+24 -2
View File
@@ -11,6 +11,7 @@ import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../state/quota_provider.dart';
import '../widgets/ad_reward_dialog.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/connect_button.dart';
import '../widgets/country_code.dart';
@@ -52,11 +53,18 @@ class ConnectPage extends ConsumerWidget {
VpnPhase.on => t.capOn,
};
// 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。
final isDesktop = context.formFactor == FormFactor.desktop;
final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off);
void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop);
final button = ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
);
@@ -75,7 +83,13 @@ class ConnectPage extends ConsumerWidget {
final infoChildren = <Widget>[
if (isFree)
QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue),
_CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes),
];
@@ -100,6 +114,8 @@ class ConnectPage extends ConsumerWidget {
offLabel: t.connectNow,
secureLabel: t.secure,
size: 176,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
),
const SizedBox(height: 24),
@@ -120,7 +136,13 @@ class ConnectPage extends ConsumerWidget {
const SizedBox(height: 24),
SizedBox(
width: 340,
child: QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
child: QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
),
],
if (conn.phase == VpnPhase.on) ...[
+1 -1
View File
@@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget {
Widget build(BuildContext context) {
final c = context.pangolin;
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
final unavail = lang.unavailable;
return Opacity(
opacity: down ? 0.55 : 1.0,
child: Material(
+54 -22
View File
@@ -9,9 +9,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/web_launch.dart';
import '../state/app_providers.dart';
import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
final info = await ref.read(updateProvider.notifier).forceCheck();
if (!context.mounted) return;
if (info == null) {
showPangolinToast(context, t.updateCheckFailed);
return;
}
if (!info.hasUpdate) {
showPangolinToast(context, t.updateUpToDate);
return;
}
// 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。
// 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。
ref.read(dismissedUpdateVersionProvider.notifier).state = null;
showPangolinToast(context, t.updateAvailableTitle(info.latestVersion));
}
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@@ -52,14 +73,15 @@ class SettingsPage extends ConsumerWidget {
const SizedBox(height: 16),
// 配置组
_Card(children: [
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)),
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))),
_Row(
title: t.darkAppearance,
sub: isDark ? t.stateOn : t.followLight,
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
),
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
]),
]),
@@ -127,27 +149,37 @@ class _LangSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = context.pangolin;
Widget seg(AppLang v, String label) {
final on = lang == v;
return GestureDetector(
onTap: () => onPick(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: on ? c.accent : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
// 6 种语言用下拉(段控横排会挤)。当前语言以本地名 + 下拉箭头展示。
return PopupMenuButton<AppLang>(
initialValue: lang,
onSelected: onPick,
color: c.surface,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
itemBuilder: (_) => [
for (final l in AppLang.values)
PopupMenuItem<AppLang>(
value: l,
height: 42,
child: Text(l.nativeLabel,
style: PangolinText.caption.copyWith(
color: l == lang ? c.accent : c.fg1,
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
fontSize: 13)),
),
child: Text(label,
style: PangolinText.caption.copyWith(
color: on ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12)),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [seg(AppLang.zh, '中文'), seg(AppLang.en, 'EN')]),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration:
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(lang.nativeLabel,
style: PangolinText.caption
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12)),
const SizedBox(width: 4),
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
]),
),
);
}
}