feat(client/ios): iPad tablet 正式适配——断点≥900 对齐设计源 + tablet golden 回归闸
- form_factor 触屏 tablet 阈值 600→900,对齐 design/ui_kits/tablet(1180×820 横屏 侧栏):iPad 横屏/12.9″竖屏走侧栏,标准/11″竖屏回落 mobile 底 Tab,避免双栏挤压 - form_factor_test 补全断点边界(900/899)与各 iPad 尺寸用例 - 新增 tablet_pages_golden_test:四视图×明暗×中英 12 张 golden,注入演示数据 (6 节点/周流量/30 天用量)让 2 列网格与周柱满渲染,作回归闸 - design/CONTRACT.md:tablet 像素契约(取自 tabapp.jsx)+ 逐屏验收清单 - 真机 iPad mini 6 视觉签核通过;141 测试全绿 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -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<void> saveTokens({required String access, required String refresh}) async {}
|
||||
@override
|
||||
Future<String?> loadAccessToken() async => null;
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
@override
|
||||
Future<void> saveLastEmail(String email) async {}
|
||||
@override
|
||||
Future<String?> loadLastEmail() async => null;
|
||||
}
|
||||
|
||||
// ── 演示数据(让网格/柱图/账户有内容;不依赖登录态与网络)─────────────
|
||||
const List<Node> _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<UsagePoint> _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<List<Node>> build() async => _demoNodes;
|
||||
}
|
||||
|
||||
class _FakeMeNotifier extends MeNotifier {
|
||||
@override
|
||||
Future<Me> build() async => _demoMe;
|
||||
}
|
||||
|
||||
const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏
|
||||
|
||||
Future<void> _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));
|
||||
}
|
||||
@@ -15,12 +15,23 @@ Future<FormFactor> _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);
|
||||
});
|
||||
|
||||