feat(client): tablet 外壳 + 视觉 diff 工作流文档
- tablet_shell: 侧栏 232·4 项触控(minHeight 48, 字号 15) + 顶栏无主题切换 + 连接页双栏 对照 ui_kits/tablet/tabapp.jsx;home_shell 分发 tablet→TabletShell - nav_sidebar 加 dense 参数(desktop 紧凑 / tablet 触控) - content_top_bar 加 showThemeToggle + titleSize 参数 - tool/visual-diff.md: 截图 diff 工作流(design-distill 工具)+ 实现侧三路径 现状(golden 被 google_fonts 阻塞 / screencapture 受 macOS chrome 限制)+ 推荐 注: tablet 形态在 macOS 桌面平台不触发(只 desktop/mobile),运行验证需 iPad。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import '../core/responsive/form_factor.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'mobile_shell.dart';
|
||||
import 'tablet_shell.dart';
|
||||
|
||||
class HomeShell extends ConsumerWidget {
|
||||
const HomeShell({super.key});
|
||||
@@ -19,7 +20,7 @@ class HomeShell extends ConsumerWidget {
|
||||
final c = context.pangolin;
|
||||
final Widget body = switch (context.formFactor) {
|
||||
FormFactor.desktop => const DesktopShell(),
|
||||
FormFactor.tablet => const MobileShell(), // TODO: TabletShell
|
||||
FormFactor.tablet => const TabletShell(),
|
||||
FormFactor.mobile => const MobileShell(),
|
||||
};
|
||||
return Scaffold(backgroundColor: c.bg, body: body);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// tablet_shell.dart — 平板外壳(侧栏 4 项触控 + 内容区),对照 ui_kits/tablet/tabapp.jsx
|
||||
//
|
||||
// 与 desktop 区别:侧栏 232 触控尺寸、4 项(无联系/设置)、顶栏无主题切换;
|
||||
// 连接页自动走双栏(connect_page 在 formFactor==tablet 时进 isWide 分支)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../screens/account_page.dart';
|
||||
import '../screens/connect_page.dart';
|
||||
import '../screens/nodes_page.dart';
|
||||
import '../screens/stats_page.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/navigation_provider.dart';
|
||||
import '../widgets/content_top_bar.dart';
|
||||
import '../widgets/nav_sidebar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
|
||||
class TabletShell extends ConsumerWidget {
|
||||
const TabletShell({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final view = ref.watch(navViewProvider);
|
||||
|
||||
final items = <NavSidebarItem>[
|
||||
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
|
||||
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
|
||||
(icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats),
|
||||
(icon: PangolinIcons.user, label: t.tabMe, view: NavView.account),
|
||||
];
|
||||
|
||||
final titles = <NavView, String>{
|
||||
NavView.connect: t.tabConnect,
|
||||
NavView.servers: t.tabServers,
|
||||
NavView.stats: t.tabStats,
|
||||
NavView.account: t.tabMe,
|
||||
};
|
||||
|
||||
void go(NavView v) => ref.read(navViewProvider.notifier).state = v;
|
||||
|
||||
Widget content() {
|
||||
switch (view) {
|
||||
case NavView.connect:
|
||||
return ConnectPage(isWide: true, onOpenNodes: () => go(NavView.servers));
|
||||
case NavView.servers:
|
||||
return NodesPage(isWide: true, onPicked: () => go(NavView.connect));
|
||||
case NavView.stats:
|
||||
return const StatsPage(isWide: true);
|
||||
default: // account / contact / settings / plans / redeem → 账户(tablet 一级只 4 项)
|
||||
return const AccountPage(isWide: true);
|
||||
}
|
||||
}
|
||||
|
||||
return ColoredBox(
|
||||
color: c.bg,
|
||||
child: Row(children: [
|
||||
NavSidebar(items: items, width: 232, dense: false),
|
||||
Expanded(
|
||||
child: Column(children: [
|
||||
ContentTopBar(title: titles[view] ?? t.tabMe, showThemeToggle: false, titleSize: 19),
|
||||
Expanded(child: content()),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ import '../state/nodes_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class ContentTopBar extends ConsumerWidget {
|
||||
const ContentTopBar({super.key, required this.title, this.onBack});
|
||||
const ContentTopBar({super.key, required this.title, this.onBack, this.showThemeToggle = true, this.titleSize = 17});
|
||||
|
||||
final String title;
|
||||
final VoidCallback? onBack;
|
||||
final bool showThemeToggle;
|
||||
final double titleSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -40,7 +42,7 @@ class ContentTopBar extends ConsumerWidget {
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(title,
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 17)),
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: titleSize)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
conn.phase == VpnPhase.on ? '● ${node.code}' : '○',
|
||||
@@ -50,16 +52,18 @@ class ContentTopBar extends ConsumerWidget {
|
||||
color: conn.phase == VpnPhase.on ? c.success : c.fg3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: () => ref.read(themeModeProvider.notifier).state =
|
||||
isDark ? ThemeMode.light : ThemeMode.dark,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(isDark ? PangolinIcons.sun : PangolinIcons.moon, size: 18, color: c.fg2),
|
||||
if (showThemeToggle) ...[
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: () => ref.read(themeModeProvider.notifier).state =
|
||||
isDark ? ThemeMode.light : ThemeMode.dark,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(isDark ? PangolinIcons.sun : PangolinIcons.moon, size: 18, color: c.fg2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ import 'plan_badge_card.dart';
|
||||
typedef NavSidebarItem = ({IconData icon, String label, NavView view});
|
||||
|
||||
class NavSidebar extends ConsumerWidget {
|
||||
const NavSidebar({super.key, required this.items, this.width = 204});
|
||||
const NavSidebar({super.key, required this.items, this.width = 204, this.dense = true});
|
||||
|
||||
final List<NavSidebarItem> items;
|
||||
final double width;
|
||||
|
||||
/// dense=true 桌面紧凑(行高~36);dense=false 平板触控(行高≥48, 字号更大)。
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
@@ -32,17 +35,18 @@ class NavSidebar extends ConsumerWidget {
|
||||
color: c.bgSubtle,
|
||||
border: Border(right: BorderSide(color: c.border)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
||||
padding: EdgeInsets.fromLTRB(dense ? 12 : 14, dense ? 14 : 18, dense ? 12 : 14, dense ? 14 : 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
// 品牌区
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 16),
|
||||
padding: EdgeInsets.fromLTRB(6, 2, 6, dense ? 16 : 20),
|
||||
child: Row(children: [
|
||||
const PangolinMark(size: 28),
|
||||
const SizedBox(width: 9),
|
||||
PangolinMark(size: dense ? 28 : 30),
|
||||
SizedBox(width: dense ? 9 : 10),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(t.brand,
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 16, height: 1)),
|
||||
style: PangolinText.h3.copyWith(
|
||||
color: c.fg1, fontWeight: FontWeight.w700, fontSize: dense ? 16 : 17, height: 1)),
|
||||
const SizedBox(height: 3),
|
||||
Text('PANGOLIN',
|
||||
style: PangolinText.caption.copyWith(
|
||||
@@ -53,11 +57,12 @@ class NavSidebar extends ConsumerWidget {
|
||||
// Nav 列表
|
||||
for (final item in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
padding: EdgeInsets.only(bottom: dense ? 3 : 4),
|
||||
child: _NavItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
active: current == item.view,
|
||||
dense: dense,
|
||||
onTap: () => ref.read(navViewProvider.notifier).state = item.view,
|
||||
),
|
||||
),
|
||||
@@ -69,11 +74,12 @@ class NavSidebar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({required this.icon, required this.label, required this.active, required this.onTap});
|
||||
const _NavItem({required this.icon, required this.label, required this.active, required this.onTap, this.dense = true});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -84,17 +90,20 @@ class _NavItem extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 18, color: active ? c.accent : c.fg3),
|
||||
const SizedBox(width: 11),
|
||||
Text(label,
|
||||
style: PangolinText.sm.copyWith(
|
||||
color: active ? c.accent : c.fg2,
|
||||
fontSize: 13.5,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w500)),
|
||||
]),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: dense ? 0 : 48),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: dense ? 12 : 14, vertical: dense ? 9 : 13),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: dense ? 18 : 20, color: active ? c.accent : c.fg3),
|
||||
SizedBox(width: dense ? 11 : 12),
|
||||
Text(label,
|
||||
style: PangolinText.sm.copyWith(
|
||||
color: active ? c.accent : c.fg2,
|
||||
fontSize: dense ? 13.5 : 15,
|
||||
fontWeight: active ? (dense ? FontWeight.w600 : FontWeight.w700) : FontWeight.w500)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 视觉 diff 工作流(pangolin 客户端 ↔ design/ui_kits 原型)
|
||||
|
||||
用 `design-distill` skill 的截图 diff 工具,把 Flutter 实现与 `design/ui_kits/{desktop,tablet,mobile}` React 原型对照。
|
||||
|
||||
## 工具
|
||||
- 原型截图:`~/.claude/skills/design-distill/tools/shoot-prototype.mjs`
|
||||
- 像素 diff:`~/.claude/skills/design-distill/tools/diff.mjs`
|
||||
|
||||
## 原型基准(已验证可用)
|
||||
从项目根运行(脚本自动起临时 http server,让 Babel 原型能 fetch jsx):
|
||||
|
||||
```bash
|
||||
SHOOT=~/.claude/skills/design-distill/tools/shoot-prototype.mjs
|
||||
# desktop 连接页 明/暗(920×600 内容,截外层 sand 画布给点余量)
|
||||
node $SHOOT design/ui_kits/desktop/index.html shots/desktop_connect_light.png \
|
||||
--width 1000 --height 720 --theme light --lang zh --wait-for "#root > *"
|
||||
node $SHOOT design/ui_kits/desktop/index.html shots/desktop_connect_dark.png \
|
||||
--width 1000 --height 720 --theme dark --lang zh --wait-for "#root > *"
|
||||
```
|
||||
> 原型是 SPA,默认显示连接页。截其它页需给 shoot-prototype 加 `--click <navSelector>`(待补)。
|
||||
|
||||
## diff
|
||||
```bash
|
||||
node ~/.claude/skills/design-distill/tools/diff.mjs <baseline.png> <impl.png> <diff.png> --threshold 0.05
|
||||
```
|
||||
|
||||
## 实现侧截图:两条路径与现状
|
||||
|
||||
### A. Flutter golden(推荐,CI 友好)— 当前被 google_fonts 阻塞
|
||||
`flutter test --update-goldens` 截 `RepaintBoundary`,无 chrome/窗口问题,可入 CI。
|
||||
**阻塞**:组件用 `GoogleFonts.*()`,测试环境 `allowRuntimeFetching=false` 会抛异常(`test/golden/components_golden_test.dart` 因此 `@Skip`)。
|
||||
**解法**:让字体可注入——把 `pangolin_theme.dart` 的 `PangolinText` / `GoogleFonts.*TextTheme` 改为可由测试用 `FontLoader` 注册的 family 覆盖(test/fonts/ 已备 Manrope/Sora/JetBrainsMono ttf),或测试态走 bundled 字体。改完即可启用 golden 做严格回归闸。
|
||||
|
||||
### B. screencapture 真实 app(已可截,但受限)
|
||||
真实渲染、字体真实,但:macOS 原生标题栏(红绿灯) vs 原型自绘 chrome 结构不同;窗口精确定位需「辅助功能」权限。
|
||||
**用途**:作为**人工视觉对照**(生成 diff 图定位结构偏差),非严格像素闸。
|
||||
|
||||
### C. iOS 模拟器(最干净的全屏,未接)
|
||||
`xcrun simctl io booted screenshot` 截模拟器无桌面 chrome、字体真实,适合 mobile/tablet。导航多页仍需 integration_test 驱动。
|
||||
|
||||
## 建议
|
||||
- **严格回归闸** → 走 A,先做 `PangolinText` 字体可注入(一次性小重构),启用 golden。
|
||||
- **对照设计原型** → 原型基准(本文档)+ A 的 golden 截图,用 diff.mjs 比对。
|
||||
- desktop 因 chrome 差异,像素级 100% 对齐不是目标;以**布局/间距/颜色结构一致**为验收口径。
|
||||
Reference in New Issue
Block a user