86e5b6d451
- settings_page.dart: smartRoute 布尔开关行 → 分流规则下钻行(标题 + 当前模式 pill + chevron),点击 desktop/tablet 切 navViewProvider 内容区下钻,mobile 全屏 push RoutingScreen(与 account_page.dart 的 open() 双形态导航同一范式)。 - navigation_provider.dart: 新增 NavView.routing + kSettingsSubViews 集合(登记 settings 的下钻子页,供 shell backTarget 判断返回 settings 而非 account)。 - desktop_shell.dart/tablet_shell.dart: 内容区 switch 挂 RoutingScreen(embedded: true),backTarget 优先判 kSettingsSubViews → 回 settings。 - settings_ia_test.dart: 追加下钻行断言(标题+模式pill+点击导航到 RoutingScreen)。 - 重录 desktop_settings golden(唯一因本刀布局变化受影响的 golden)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
9.9 KiB
Dart
248 lines
9.9 KiB
Dart
// settings_page.dart — 设置(desktop 一级页)
|
|
//
|
|
// 三组:连接(开机自启/自动连接/智能分流/kill-switch 开关)· 外观(语言/深色)·
|
|
// 关于(协议/检查更新/版本 + Web 用户中心链接)。购买/订单入口已归 Account 页。
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../core/responsive/form_factor.dart';
|
|
import '../l10n/app_text.dart';
|
|
import '../pangolin_theme.dart';
|
|
import '../services/web_launch.dart';
|
|
import '../state/app_providers.dart';
|
|
import '../state/navigation_provider.dart';
|
|
import '../state/routing_provider.dart';
|
|
import '../state/settings_provider.dart';
|
|
import '../state/update_provider.dart';
|
|
import '../widgets/page_body.dart';
|
|
import '../widgets/pangolin_icons.dart';
|
|
import '../widgets/pangolin_toast.dart';
|
|
import '../widgets/routing_screen.dart';
|
|
|
|
/// 「分流规则」当前模式短标签(与 [RoutingScreen] 段选顺序一致的三态)。
|
|
String _routingModeLabel(AppText t, String mode) => switch (mode) {
|
|
'global' => t.routingModeGlobal,
|
|
'direct' => t.routingModeDirect,
|
|
_ => t.routingModeSmart, // 'rule' + 兜底
|
|
};
|
|
|
|
/// 打开「分流规则」子屏:desktop/tablet 在内容区下钻(切 navViewProvider,顶栏带返回到
|
|
/// settings);mobile 全屏 push(RoutingScreen 自带返回箭头,同 account_page.dart 的
|
|
/// mobile 全屏 push 范式)。
|
|
void _openRouting(BuildContext context, WidgetRef ref) {
|
|
if (context.formFactor == FormFactor.mobile) {
|
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RoutingScreen()));
|
|
} else {
|
|
ref.read(navViewProvider.notifier).state = NavView.routing;
|
|
}
|
|
}
|
|
|
|
/// 「检查更新」手动触发:拉取 `$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});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final c = context.pangolin;
|
|
final t = ref.watch(appTextProvider);
|
|
final lang = ref.watch(localeProvider);
|
|
final mode = ref.watch(themeModeProvider);
|
|
final isDark = mode == ThemeMode.dark;
|
|
final s = ref.watch(settingsProvider);
|
|
final settings = ref.read(settingsProvider.notifier);
|
|
final version = ref.watch(appVersionProvider).valueOrNull ?? '…';
|
|
|
|
Widget sw(bool v, ValueChanged<bool> on) =>
|
|
Switch(value: v, activeThumbColor: c.accent, onChanged: on);
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(32, 16, 32, 24),
|
|
child: PageBody(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
// ① 连接(功能开关组;开机自启是桌面端登录项概念,iOS/Android 无此能力,仅桌面显示)
|
|
_SectionLabel(text: t.settingsGroupConn),
|
|
_Card(children: [
|
|
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux)
|
|
_Row(title: t.autostart, sub: t.autostartSub, right: sw(s.autostart, settings.setAutostart)),
|
|
_Row(title: t.autoConnect, sub: t.autoConnectSub, right: sw(s.autoConnect, settings.setAutoConnect)),
|
|
_Row(
|
|
title: t.routingRulesTitle,
|
|
right: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
_ModePill(label: _routingModeLabel(t, ref.watch(routingProfileProvider).valueOrNull?.mode ?? 'rule')),
|
|
const SizedBox(width: 4),
|
|
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
|
]),
|
|
onTap: () => _openRouting(context, ref),
|
|
),
|
|
_Row(title: t.killSwitch, sub: t.killSwitchSub, last: true, right: sw(s.killSwitch, settings.setKillSwitch)),
|
|
]),
|
|
const SizedBox(height: 20),
|
|
// ② 外观(语言 + 深色)
|
|
_SectionLabel(text: t.settingsGroupAppearance),
|
|
_Card(children: [
|
|
_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,
|
|
last: true,
|
|
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
|
|
),
|
|
]),
|
|
const SizedBox(height: 20),
|
|
// ③ 关于(协议 + 检查更新 + 版本 + 底部 Web 用户中心链接)
|
|
_SectionLabel(text: t.settingsGroupAbout),
|
|
_Card(children: [
|
|
_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: () => _checkForUpdate(context, ref, t)),
|
|
_Row(title: 'Version', right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
|
_Row(title: t.webUserCenter, last: true, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
|
|
]),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
const _SectionLabel({required this.text});
|
|
final String text;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
|
|
child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Card extends StatelessWidget {
|
|
const _Card({required this.children});
|
|
final List<Widget> children;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
border: Border.all(color: c.border),
|
|
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(children: children),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Row extends StatelessWidget {
|
|
const _Row({required this.title, this.sub, required this.right, this.last = false, this.onTap});
|
|
final String title;
|
|
final String? sub;
|
|
final Widget right;
|
|
final bool last;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
border: last ? null : Border(bottom: BorderSide(color: c.border)),
|
|
),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(title, style: PangolinText.body.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w500)),
|
|
if (sub != null) Text(sub!, style: PangolinText.caption.copyWith(color: c.fg3)),
|
|
]),
|
|
),
|
|
right,
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 「分流规则」行右侧的当前模式小药丸(全局代理/智能分流/全部直连)。
|
|
class _ModePill extends StatelessWidget {
|
|
const _ModePill({required this.label});
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
|
child: Text(label, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 11)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LangSwitch extends StatelessWidget {
|
|
const _LangSwitch({required this.lang, required this.onPick});
|
|
final AppLang lang;
|
|
final ValueChanged<AppLang> onPick;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
// 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: 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),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|