diff --git a/client/lib/core/responsive/form_factor.dart b/client/lib/core/responsive/form_factor.dart index 159519f..519db44 100644 --- a/client/lib/core/responsive/form_factor.dart +++ b/client/lib/core/responsive/form_factor.dart @@ -20,11 +20,15 @@ bool get _isDesktopPlatform { } extension FormFactorContext on BuildContext { - /// 当前形态。桌面平台(macOS/Win/Linux)且宽≥680 → desktop;触屏/Web 宽≥600 → tablet;否则 mobile。 + /// 当前形态。桌面平台(macOS/Win/Linux)且宽≥680 → desktop;触屏/Web 宽≥900 → tablet;否则 mobile。 + /// + /// tablet 阈值 900 对齐 iPad 设计源(design/ui_kits/tablet:1180×820 横屏侧栏布局, + /// README 定「宽 ≥900 才从底部 Tab 切侧栏」):iPad 横屏/大屏(12.9″竖屏)→ 侧栏 tablet; + /// 小 iPad 竖屏(≤834)→ 回落 mobile 底 Tab,避免双栏在窄屏挤压。 FormFactor get formFactor { final w = MediaQuery.sizeOf(this).width; if (_isDesktopPlatform) return w >= 680 ? FormFactor.desktop : FormFactor.mobile; - if (w >= 600) return FormFactor.tablet; + if (w >= 900) return FormFactor.tablet; return FormFactor.mobile; } diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png new file mode 100644 index 0000000..d99c397 Binary files /dev/null and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png new file mode 100644 index 0000000..5f1c625 Binary files /dev/null and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png new file mode 100644 index 0000000..9e4d63e Binary files /dev/null and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_dark_zh.png b/client/test/golden/goldens/tablet_connect_dark_zh.png new file mode 100644 index 0000000..6b870e1 Binary files /dev/null and b/client/test/golden/goldens/tablet_connect_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_en.png b/client/test/golden/goldens/tablet_connect_light_en.png new file mode 100644 index 0000000..acb7b69 Binary files /dev/null and b/client/test/golden/goldens/tablet_connect_light_en.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_zh.png b/client/test/golden/goldens/tablet_connect_light_zh.png new file mode 100644 index 0000000..6bd9ad7 Binary files /dev/null and b/client/test/golden/goldens/tablet_connect_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png new file mode 100644 index 0000000..d02ac27 Binary files /dev/null and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png new file mode 100644 index 0000000..e944598 Binary files /dev/null and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png new file mode 100644 index 0000000..845f807 Binary files /dev/null and b/client/test/golden/goldens/tablet_servers_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png new file mode 100644 index 0000000..000337c Binary files /dev/null and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png new file mode 100644 index 0000000..aaf6215 Binary files /dev/null and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png new file mode 100644 index 0000000..d3833e6 Binary files /dev/null and b/client/test/golden/goldens/tablet_stats_light_zh.png differ diff --git a/client/test/golden/tablet_pages_golden_test.dart b/client/test/golden/tablet_pages_golden_test.dart new file mode 100644 index 0000000..a0a6399 --- /dev/null +++ b/client/test/golden/tablet_pages_golden_test.dart @@ -0,0 +1,153 @@ +// tablet_pages_golden_test.dart — iPad/tablet 整页 golden(连接/节点/统计/账户) +// +// 用 debugDefaultTargetPlatformOverride=iOS + 1180×820 surface 让 context.formFactor +// 判定为 tablet(宽 ≥900),渲染 TabletShell 各页,对照 design/ui_kits/tablet/tabapp.jsx +// 像素基准做保真校准 / 回归闸。注入演示数据(节点/用量/账户)让网格与柱图有内容; +// 覆盖矩阵 = 四视图 × {light,dark} × {zh,en}。生成基准:flutter test --update-goldens。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/models/me.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/models/usage_point.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/shell/tablet_shell.dart'; +import 'package:pangolin_vpn/state/account_providers.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/nodes_provider.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +// ── 演示数据(让网格/柱图/账户有内容;不依赖登录态与网络)───────────── +const List _demoNodes = [ + Node(code: 'HK', nameZh: '香港 · 流媒体', nameEn: 'Hong Kong', ping: 18, tag: NodeTag.streaming, host: 'hk', port: 443), + Node(code: 'TW', nameZh: '台北', nameEn: 'Taipei', ping: 28, host: 'tw', port: 443), + Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 32, host: 'jp', port: 443), + Node(code: 'KR', nameZh: '首尔', nameEn: 'Seoul', ping: 38, host: 'kr', port: 443), + Node(code: 'SG', nameZh: '新加坡', nameEn: 'Singapore', ping: 45, tag: NodeTag.p2p, host: 'sg', port: 443), + Node(code: 'US', nameZh: '洛杉矶', nameEn: 'Los Angeles', ping: 120, host: 'us', port: 443), +]; + +const Me _demoMe = Me( + email: 'me@pangolin.vpn', + plan: 'free', + devicesUsed: 2, + devicesMax: 1, + quotaTodayMin: 6, + dataTodayGb: 1.2, + weeklyGb: [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0], +); + +final List _demoUsage = List.generate(30, (i) { + return UsagePoint( + date: '2026-06-${(i + 1).toString().padLeft(2, '0')}', + bytesUp: 120 * 1024 * 1024 + i * 7 * 1024 * 1024, + bytesDown: 900 * 1024 * 1024 + i * 31 * 1024 * 1024, + minutesUsed: 24 + (i % 5) * 11, + ); +}); + +class _FakeNodesNotifier extends NodesNotifier { + @override + Future> build() async => _demoNodes; +} + +class _FakeMeNotifier extends MeNotifier { + @override + Future build() async => _demoMe; +} + +const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏 + +Future _shoot( + WidgetTester tester, + NavView view, + String name, { + bool settle = true, + Brightness brightness = Brightness.light, + AppLang lang = AppLang.zh, +}) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = _ipad; + 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()), + navViewProvider.overrideWith((ref) => view), + localeProvider.overrideWith((ref) => lang), + nodesProvider.overrideWith(_FakeNodesNotifier.new), + meProvider.overrideWith(_FakeMeNotifier.new), + usageProvider(30).overrideWith((ref) async => _demoUsage), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: brightness == Brightness.dark ? PangolinTheme.dark : PangolinTheme.light, + home: const HomeShell(), + ), + )); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + await expectLater(find.byType(TabletShell), matchesGoldenFile('goldens/$name.png')); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +void main() { + // 连接页 off 态含连接键虚线环静态,但稳妥起见用 settle:false(pump 固定帧)。 + for (final lang in const [AppLang.zh, AppLang.en]) { + final l = lang == AppLang.zh ? 'zh' : 'en'; + testWidgets('tablet 连接·light·$l', + (t) => _shoot(t, NavView.connect, 'tablet_connect_light_$l', settle: false, lang: lang)); + testWidgets('tablet 节点·light·$l', + (t) => _shoot(t, NavView.servers, 'tablet_servers_light_$l', lang: lang)); + testWidgets('tablet 统计·light·$l', + (t) => _shoot(t, NavView.stats, 'tablet_stats_light_$l', lang: lang)); + testWidgets('tablet 账户·light·$l', + (t) => _shoot(t, NavView.account, 'tablet_account_light_$l', lang: lang)); + } + + // 深色覆盖(中文,验证暗主题各视图)。 + testWidgets('tablet 连接·dark·zh', + (t) => _shoot(t, NavView.connect, 'tablet_connect_dark_zh', settle: false, brightness: Brightness.dark)); + testWidgets('tablet 节点·dark·zh', + (t) => _shoot(t, NavView.servers, 'tablet_servers_dark_zh', brightness: Brightness.dark)); + testWidgets('tablet 统计·dark·zh', + (t) => _shoot(t, NavView.stats, 'tablet_stats_dark_zh', brightness: Brightness.dark)); + testWidgets('tablet 账户·dark·zh', + (t) => _shoot(t, NavView.account, 'tablet_account_dark_zh', brightness: Brightness.dark)); +} diff --git a/client/test/responsive/form_factor_test.dart b/client/test/responsive/form_factor_test.dart index 987a3e3..f456277 100644 --- a/client/test/responsive/form_factor_test.dart +++ b/client/test/responsive/form_factor_test.dart @@ -15,12 +15,23 @@ Future _ffAt(WidgetTester tester, Size size) async { } void main() { - testWidgets('iPad 竖屏(768) → tablet', (t) async { - expect(await _ffAt(t, const Size(768, 1024)), FormFactor.tablet); - }); + // tablet 阈值 ≥900,对齐 iPad 设计源(横屏侧栏布局)。 testWidgets('iPad 横屏(1024) → tablet', (t) async { expect(await _ffAt(t, const Size(1024, 768)), FormFactor.tablet); }); + testWidgets('iPad 12.9″ 竖屏(1024) → tablet', (t) async { + expect(await _ffAt(t, const Size(1024, 1366)), FormFactor.tablet); + }); + testWidgets('iPad 11″ 竖屏(834) → mobile(窄屏回落底 Tab)', (t) async { + expect(await _ffAt(t, const Size(834, 1194)), FormFactor.mobile); + }); + testWidgets('iPad 标准竖屏(768) → mobile', (t) async { + expect(await _ffAt(t, const Size(768, 1024)), FormFactor.mobile); + }); + testWidgets('断点边界:900 → tablet, 899 → mobile', (t) async { + expect(await _ffAt(t, const Size(900, 1200)), FormFactor.tablet); + expect(await _ffAt(t, const Size(899, 1200)), FormFactor.mobile); + }); testWidgets('iPhone(390) → mobile', (t) async { expect(await _ffAt(t, const Size(390, 844)), FormFactor.mobile); }); diff --git a/design/CONTRACT.md b/design/CONTRACT.md new file mode 100644 index 0000000..b17df3d --- /dev/null +++ b/design/CONTRACT.md @@ -0,0 +1,81 @@ +# 设计契约 · iPad / Tablet(design-distill) + +> 像素基准:`design/ui_kits/tablet/tabapp.jsx`(1180×820 横屏侧栏布局)+ `index.html`。 +> 数值取自原型源码,非目测。token 单源:`design/colors_and_type.css` → `client/lib/pangolin_tokens.gen.dart`(勿手改)。 +> 目标实现:`client/lib/shell/tablet_shell.dart` + 四页 `isWide` 分支 + `client/lib/widgets/`。 +> 验收闸:`client/test/golden/tablet_pages_golden_test.dart`(1180×820 / iOS 平台 → formFactor=tablet)。 + +## 0. 归一化产物 +- 类型 A(结构化包):token 单源已存在、codegen 已接(`design/codegen/gen_flutter_tokens.mjs`)。本契约只补 **tablet 页面像素规格 + 验收清单**。 +- 断点:触屏宽 **≥900 → tablet 侧栏**,否则 mobile 底 Tab(`client/lib/core/responsive/form_factor.dart`,对齐 README「≥900 切侧栏」)。 + +## 1. token 映射(复用既有语义层) +| 原型 CSS var | Flutter 语义层 | +|---|---| +| `--bg` / `--bg-subtle` | `c.bg` / `c.bgSubtle` | +| `--surface` | `c.surface` | +| `--fg1/2/3` | `c.fg1/fg2/fg3` | +| `--accent` / `--accent-subtle` / `--accent-border` | `c.accent` / `c.accentSubtle` / `c.accentBorder` | +| `--success` `--warning` | `c.success` `c.warning` | +| `--border` / `--border-strong` | `c.border` / `c.borderStrong` | +| `--radius-{sm,md,lg,xl,full}` | `PangolinRadius.{sm,md,lg,xl,full}` | +| `--shadow-{sm,md,lg}` | `PangolinShadow.{sm,md,lg}` | +| `--font-{display,sans,mono}` | `PangolinText.{h*/body...}` / `PangolinText.mono` | + +## 2. 组件映射 +| 原型 | Flutter | 状态 | +|---|---|---| +| `TabRail` 侧栏 | `TabletShell` + `widgets/nav_sidebar.dart` | 已接,宽 232、行高≥48 | +| 连接键三态 | `widgets/connect_button.dart` | 复用(off 态 canonical 走「虚线轨道环」,见 design/CLAUDE.md §5,优先于原型实心环简化) | +| 免费额度卡 | `widgets/quota_card.dart` | 复用 | +| 智能选择卡 | `widgets/smart_select_card.dart` | 复用 | +| 节点行 | `widgets/server_tile.dart` / `nodes_page` `_NodeGridTile` | 双列网格 | +| 指标卡 | `stats_page` `_MetricCard` | 复用 | +| 段控/开关/行 | `account_page` 内组件 | 复用 | + +## 3. 页面像素规格(取自 tabapp.jsx) + +### 外壳 TabletApp +- 内屏 1180×820,`data-theme` 切明暗。侧栏 + 内容列(顶栏 56 + 主体)。 +- 内容顶栏:高 56,padding `0 28`,标题 `font-display 700 / 19 / fg1`,右侧连接状态(mono 12.5,on=success「● {code}」/ off=fg3「○」)。 + +### TabRail 侧栏 +- 宽 232,`bg-subtle`,右 1px `border`,padding `18 14 16`。 +- 品牌头:Mark 30 + 「穿山甲」(display 700/17) + 「PANGOLIN」(9/600/字距0.2em/accent),gap 10,padding `0 8 20`。 +- 导航项:gap 4;每项 padding `13 14`、radius md、字号 15、active 700+accent+accent-subtle 底 / inactive 500+fg2、**minHeight 48**、图标 20(active stroke 2.4)、gap 12。 +- 底部套餐徽章:free=surface+border / pro=`linear-gradient(150deg,clay-600,clay-800)`+白字;radius lg,padding `12 13`;头像圈 32 + 名(12.5/700) + 邮箱(10.5/opacity.65)。 + +### 连接视图 TabConnect(双栏) +- 左列 flex **1.25**,居中,gap 26:按钮 216×216 圆、`box-shadow 0 0 0 11px {ring}, shadow-lg`、图标 60、label mono 700/14/字距0.08em;下方 caption(16/600/fg2),on 态计时 mono 28/fg1。 +- 右列宽 **332**,gap 14,垂直居中,paddingRight 28: + - 免费额度卡(card padding `14 16`):clock + 「今日剩余 {n} 分钟」+ 「免费版」pill + 进度条(高6,≤3 变 warning) + 看广告按钮(accent-subtle/accent-border/full/minHeight44)。 + - 当前节点卡(padding `13 15`, minHeight 48):CC 码块 + 「当前节点」(11/600/fg3) + 名(15/600) + 副(12/fg3) + chevron。 + - on 态速率行:3 卡(下载/上传/延迟),各 padding `11 13`、值 mono 16。 + +### 节点视图 TabNodes +- padding `6 28 24`,overflow auto。 +- 智能卡:accent-subtle 底、border(active=accent)、radius xl、padding `15 16`、minHeight 56、marginBottom 14;zap 在 clay 渐变盒 42×42 + 「智能选择」(15.5/700) + 「推荐」pill + 副(12/fg2) + active check。 +- 搜索框:bg-subtle、radius md、padding `11 14`、maxWidth 380、marginBottom 14。 +- 网格:**2 列 `1fr 1fr`,gap 11**;每格 padding `13 15`、minHeight 56、active=accent-subtle+accent-border:CC + 名(14.5/600) + 副 + ping(mono 12) + Signal + active check。 + +### 统计视图 TabStats +- padding `6 28 24`。 +- 3 指标卡一行,gap 14,marginBottom 20;各 padding `18 20`:label(12.5/fg3/600) + 值(mono 26/fg1)。 +- 周柱卡 padding `20 22`:标题(13.5/600) + 7 柱(gap 22, 容器高150, 柱 maxWidth 40, accent opacity .85, radius `6 6 0 0`) + 值(mono 10.5) + 星期(11.5)。 + +### 账户视图 TabAccount +- padding `6 28 24`,maxWidth 640(生产页功能更丰富,沿用同设计语言:套餐横幅 + 分区卡 + 行 + 段控/开关)。 +- 套餐横幅 radius xl padding 20;行 minHeight 48,图标盒 34 radius sm accent-subtle;TabToggle 48×29(on=accent)。 + +## 4. 逐屏验收清单(每屏 × 明/暗 × 中/英) +- [ ] 连接 / 节点 / 统计 / 账户 四视图 × light/dark × zh/en:无溢出、侧栏 232 比例正确、触控目标 ≥48。 +- [ ] 连接页双栏左右比例 ≈ 1.25 : 332;on 态速率行 3 卡出现。 +- [ ] 节点页 2 列网格 + 置顶智能卡;搜索框 maxWidth 380。 +- [ ] 颜色全部语义 token、无硬编码 hex(design/CLAUDE.md §1)。 +- [ ] 文案无红线词(§13)、双语单显不并排。 +- [ ] golden 回归闸 `tablet_pages_golden_test.dart` 通过;真机/模拟器 mini/11″/12.9″ 横竖屏目测过。 + +## 5. 已知差异(记录,非缺陷) +- 连接键 off 态:原型用实心 11px 环,Flutter 用「虚线轨道环」(design/CLAUDE.md §5 canonical 连接键),以 §5 为准。 +- 账户页:生产实现比原型多设备管理/兑换/联系/协议行,属真实功能扩展,沿用同设计语言。 +- golden 测试环境未打包 Noto Sans SC → CJK 显示为方块,仅影响 golden 文字、不影响真机;布局可判。节点网格/周柱在未登录测试态无数据(需注入演示数据方显内容)。