feat(routing): 设置入口改分流规则下钻行 + 双形态导航
- 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>
This commit is contained in:
@@ -7,15 +7,37 @@ 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(不弹窗)。
|
||||
@@ -63,7 +85,15 @@ class SettingsPage extends ConsumerWidget {
|
||||
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.smartRoute, sub: t.smartRouteSub, right: sw(s.smartRoute, settings.setSmartRoute)),
|
||||
_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),
|
||||
@@ -157,6 +187,22 @@ class _Row extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「分流规则」行右侧的当前模式小药丸(全局代理/智能分流/全部直连)。
|
||||
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;
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../widgets/account_screens.dart';
|
||||
import '../widgets/content_top_bar.dart';
|
||||
import '../widgets/nav_sidebar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/routing_screen.dart';
|
||||
|
||||
class DesktopShell extends ConsumerWidget {
|
||||
const DesktopShell({super.key});
|
||||
@@ -56,6 +57,7 @@ class DesktopShell extends ConsumerWidget {
|
||||
NavView.orders: t.ordersTitle,
|
||||
NavView.invite: t.inviteTitle,
|
||||
NavView.notifications: t.notifTitle,
|
||||
NavView.routing: t.routingRulesTitle,
|
||||
};
|
||||
|
||||
void go(NavView v) => ref.read(navViewProvider.notifier).state = v;
|
||||
@@ -89,14 +91,18 @@ class DesktopShell extends ConsumerWidget {
|
||||
return InviteScreen(t: t, embedded: true);
|
||||
case NavView.notifications:
|
||||
return NotificationsScreen(t: t, embedded: true);
|
||||
// 设置下钻子页(内容区,顶栏带返回到 settings)
|
||||
case NavView.routing:
|
||||
return const RoutingScreen(embedded: true);
|
||||
}
|
||||
}
|
||||
|
||||
// 子页返回目标:payment → purchase(换方式/失败重试仍在购买语境内);
|
||||
// 其余下钻子页(含 purchase 本身)→ account。
|
||||
// 设置下钻子页 → settings;其余下钻子页(含 purchase 本身)→ account。
|
||||
VoidCallback? backTarget() {
|
||||
if (view == NavView.payment) return () => go(NavView.purchase);
|
||||
if (view == NavView.notifications) return () => go(NavView.account);
|
||||
if (kSettingsSubViews.contains(view)) return () => go(NavView.settings);
|
||||
if (kAccountSubViews.contains(view)) return () => go(NavView.account);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../state/navigation_provider.dart';
|
||||
import '../widgets/content_top_bar.dart';
|
||||
import '../widgets/nav_sidebar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/routing_screen.dart';
|
||||
|
||||
class TabletShell extends ConsumerWidget {
|
||||
const TabletShell({super.key});
|
||||
@@ -46,6 +47,7 @@ class TabletShell extends ConsumerWidget {
|
||||
NavView.contact: t.contactTitle,
|
||||
NavView.invite: t.inviteTitle,
|
||||
NavView.notifications: t.notifTitle,
|
||||
NavView.routing: t.routingRulesTitle,
|
||||
};
|
||||
|
||||
void go(NavView v) => ref.read(navViewProvider.notifier).state = v;
|
||||
@@ -64,14 +66,17 @@ class TabletShell extends ConsumerWidget {
|
||||
return const ContactPage();
|
||||
case NavView.notifications:
|
||||
return NotificationsScreen(t: t, embedded: true);
|
||||
case NavView.routing:
|
||||
return const RoutingScreen(embedded: true);
|
||||
default: // account / plans / redeem / devices → 账户(其余下钻在账户页内)
|
||||
return const AccountPage(isWide: true);
|
||||
}
|
||||
}
|
||||
|
||||
// 通知子页返回目标:回账户(镜像桌面 desktop_shell.dart::backTarget)。
|
||||
// 通知子页返回目标:回账户;设置下钻子页 → settings(镜像桌面 desktop_shell.dart::backTarget)。
|
||||
VoidCallback? backTarget() {
|
||||
if (view == NavView.notifications) return () => go(NavView.account);
|
||||
if (kSettingsSubViews.contains(view)) return () => go(NavView.settings);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// navigation_provider.dart — 主导航当前视图(三端共享)
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 一级视图 + 账户子页。
|
||||
/// 一级视图 + 账户子页 + 设置子页。
|
||||
/// desktop 侧栏暴露 6 个一级项(connect..settings);mobile/tablet 取前 4 项,
|
||||
/// contact/settings 在 mobile 走账户子页。redeem 是 account 的下钻页。
|
||||
enum NavView { connect, servers, stats, account, contact, settings, redeem, devices, purchase, payment, orders, invite, notifications }
|
||||
/// contact/settings 在 mobile 走账户子页。redeem 是 account 的下钻页;routing 是
|
||||
/// settings 的下钻页(分流规则)。
|
||||
enum NavView { connect, servers, stats, account, contact, settings, redeem, devices, purchase, payment, orders, invite, notifications, routing }
|
||||
|
||||
/// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。
|
||||
const Set<NavView> kAccountSubViews = {NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders, NavView.invite};
|
||||
|
||||
/// 设置下钻子页(desktop/tablet 在内容区切换、顶栏带返回到 settings;mobile 走全屏 push)。
|
||||
const Set<NavView> kSettingsSubViews = {NavView.routing};
|
||||
|
||||
/// 当前视图。
|
||||
final navViewProvider = StateProvider<NavView>((ref) => NavView.connect);
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
@@ -10,6 +10,7 @@ import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/routing_profile.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
import 'package:pangolin_vpn/screens/settings_page.dart';
|
||||
import 'package:pangolin_vpn/services/token_store.dart';
|
||||
@@ -18,6 +19,8 @@ import 'package:pangolin_vpn/shell/home_shell.dart';
|
||||
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||
import 'package:pangolin_vpn/state/navigation_provider.dart';
|
||||
import 'package:pangolin_vpn/state/routing_provider.dart';
|
||||
import 'package:pangolin_vpn/widgets/routing_screen.dart';
|
||||
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
@@ -82,4 +85,52 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('settings 连接组:smartRoute 开关被「分流规则」下钻行取代(标题+当前模式pill+chevron),点击导航到 RoutingScreen',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
tester.view.physicalSize = const Size(920, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
try {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
|
||||
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
|
||||
appTextProvider.overrideWithValue(t),
|
||||
navViewProvider.overrideWith((ref) => NavView.settings),
|
||||
routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(const RoutingProfile(mode: 'global'))),
|
||||
],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
home: const HomeShell(),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 旧的 smartRoute 布尔开关行已消失。
|
||||
expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.smartRoute)), findsNothing);
|
||||
|
||||
// 新的下钻行:标题「分流规则」+ 当前模式(全局代理)pill。
|
||||
final routingRow = find.descendant(of: find.byType(SettingsPage), matching: find.text(t.routingRulesTitle));
|
||||
expect(routingRow, findsOneWidget);
|
||||
expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.routingModeGlobal)), findsOneWidget);
|
||||
|
||||
await tester.tap(routingRow);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(RoutingScreen), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeRoutingNotifier extends RoutingProfileNotifier {
|
||||
_FakeRoutingNotifier(this._v);
|
||||
final RoutingProfile _v;
|
||||
@override
|
||||
Future<RoutingProfile> build() async => _v;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user