Add mobile-safe controls for mobile shell
This commit is contained in:
parent
2c7c7e7143
commit
fe1a74c478
368
docs/plans/2026-03-21-Mobile.md
Normal file
368
docs/plans/2026-03-21-Mobile.md
Normal file
@ -0,0 +1,368 @@
|
||||
# XWorkmate Mobile 定位与补齐方案
|
||||
|
||||
## Summary
|
||||
|
||||
XWorkmate 的 iOS/Android 不应该被定义成“第二个 Code Agent 运行端”,而应该被定义成“远程操作台(remote operator console)”。执行仍然锚定在 `Mac Node Agent`,移动端负责发起任务、查看状态、处理审批、审阅 diff、触发预设动作、接收告警,并在离桌时续接会话。
|
||||
|
||||
首发链路收敛为:
|
||||
|
||||
`Mobile -> OpenClaw Gateway -> Mac Node Agent`
|
||||
|
||||
首发使用 `HTTPS + WSS` 安全直连,不把 `Tailscale/VPN` 作为前置条件,只作为后续增强。其他 Agents 不直接暴露给手机,而是统一挂在 `Mac Node Agent` 背后,由 `OpenClaw Gateway` 统一做鉴权、路由、事件分发和审批收口。
|
||||
|
||||
本方案同时补充两项明确要求:
|
||||
|
||||
- `Mobile-safe` 定义为“安全审批快捷入口”,覆盖 pairing / paired device / approve / reject / safe continue 等结构化操作。
|
||||
- iOS/Android 默认视觉语言按 `calm_compact_workspace_system` 调整,使用平台系统字体与紧凑字号,不另起一套移动品牌风格。
|
||||
|
||||
## Evidence
|
||||
|
||||
- 仓库现状:
|
||||
- XWorkmate 当前仍是 desktop-first。
|
||||
- 移动端已存在统一壳层,但本质是 UI surface,不是独立执行 runtime。
|
||||
- 参考:[README.md](../../README.md)、[xworkmate-integrations.md](../architecture/xworkmate-integrations.md)、[mobile_shell.dart](../../lib/features/mobile/mobile_shell.dart)
|
||||
- Codex 用户诉求:
|
||||
- 2025-08-27 的 `#2798` 请求 remote/headless sign-in。
|
||||
- 2025-09-02 的 `#3052` 请求 approval/job completion 通知。
|
||||
- 2025-09-05 的 `#3199` 请求 web access to local project editing。
|
||||
- 2025-09-26 的 `#4306` 请求任务完成通知。
|
||||
- 这些诉求集中在“远程接入、审批、通知、续接”,不是“手机本地执行”。
|
||||
- 第三方补位方向:
|
||||
- Taskdex 走的是“phone + relay + agent”。
|
||||
- Remote Codetrol 走的是“agent stays on Mac, phone is the remote”。
|
||||
- 两者都在验证:手机端更适合做远程控制面、审批面和状态面。
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. 角色边界
|
||||
|
||||
#### XWorkmate iOS/Android
|
||||
|
||||
- 角色:
|
||||
- 远程控制面
|
||||
- 审批面
|
||||
- 状态面
|
||||
- 轻量工作流入口
|
||||
- 不承担:
|
||||
- 代码执行
|
||||
- 仓库索引
|
||||
- CLI 进程托管
|
||||
- 长期上下文主存储
|
||||
|
||||
#### OpenClaw Gateway
|
||||
|
||||
- 角色:
|
||||
- 统一 RPC 面
|
||||
- 设备鉴权
|
||||
- 会话路由
|
||||
- 审批事件分发
|
||||
- 策略收口
|
||||
- 首发对移动端暴露:
|
||||
- `HTTPS` 负责控制、查询、审批提交、设备管理、历史收取
|
||||
- `WSS` 负责会话流、状态推送、审批请求、运行事件
|
||||
- 不承担:
|
||||
- 复杂移动推送编排
|
||||
- 长期多设备同步产品逻辑
|
||||
- 代码执行
|
||||
|
||||
#### Mac Node Agent
|
||||
|
||||
- 角色:
|
||||
- 真正的执行宿主
|
||||
- 持有 repo、shell、git、build/test、工具权限
|
||||
- 继续承载:
|
||||
- `Codex`
|
||||
- `Claude`
|
||||
- `Gemini`
|
||||
- `OpenCode`
|
||||
- 其他本地执行器
|
||||
|
||||
#### XWorkmate Cloud Control Plane
|
||||
|
||||
- 保持可选,不作为首发依赖。
|
||||
- 后续可承担:
|
||||
- push fanout
|
||||
- 设备注册
|
||||
- 离线 inbox
|
||||
- 审计索引
|
||||
- 不承担:
|
||||
- 代码执行
|
||||
- 手机到桌面的必经数据面
|
||||
|
||||
### 2. 首发拓扑
|
||||
|
||||
```text
|
||||
Mobile
|
||||
-> HTTPS / WSS
|
||||
OpenClaw Gateway
|
||||
-> routed control / session / approval events
|
||||
Mac Node Agent
|
||||
-> local agents: Codex / Claude / Gemini / OpenCode / others
|
||||
```
|
||||
|
||||
- 首发链路:`Mobile -> OpenClaw Gateway -> Mac Node Agent`
|
||||
- 首发安全通道:`HTTPS + WSS`
|
||||
- `Tailscale/VPN`:
|
||||
- 不作为 v1 前置条件
|
||||
- 写成后续可叠加增强
|
||||
- 其他 Agents:
|
||||
- 首发统一挂在 `Mac Node Agent` 后
|
||||
- 不为移动端单独暴露第二套直连协议
|
||||
|
||||
### 3. iOS 与 Android 产品定义
|
||||
|
||||
#### iOS
|
||||
|
||||
- 首发优先承担“高级远程控制器”角色
|
||||
- 重点:
|
||||
- Live Activities
|
||||
- 推送审批
|
||||
- 运行状态跟踪
|
||||
- 快速回复
|
||||
|
||||
#### Android
|
||||
|
||||
- 并行承担“全功能控制器”角色
|
||||
- 重点:
|
||||
- 通知
|
||||
- 后台保活
|
||||
- Quick Actions
|
||||
- diff review
|
||||
|
||||
#### 共同边界
|
||||
|
||||
- 两端都不做“手机 IDE”
|
||||
- 两端都不本地启动 `Codex/CLI`
|
||||
- 两端都不绕过现有 `Gateway / pairing / device token` 安全边界
|
||||
|
||||
## Implementation Changes
|
||||
|
||||
### A. 先复用的现有能力
|
||||
|
||||
- 复用现有 `MobileShell` 和 workspace page surface,继续沿用“移动端统一壳层”方向。
|
||||
- 复用现有 Gateway pairing 与 device token 机制作为移动端设备身份底座。
|
||||
- 复用现有 session / chat / runtime controller,不另造第二套 agent 协议。
|
||||
|
||||
参考:
|
||||
|
||||
- [gateway-dev-runbook.md](../runbooks/gateway-dev-runbook.md)
|
||||
- [app_controller.dart](../../lib/app/app_controller.dart)
|
||||
- [gateway_runtime.dart](../../lib/runtime/gateway_runtime.dart)
|
||||
|
||||
### B. 需要补充的核心能力
|
||||
|
||||
#### Remote Session Contract
|
||||
|
||||
- 增加移动端专用 session 元数据:
|
||||
- `run status`
|
||||
- `awaiting approval`
|
||||
- `last diff summary`
|
||||
- `pending question`
|
||||
- `safe quick actions`
|
||||
- 明确区分:
|
||||
- 会话浏览权限
|
||||
- 会话控制权限
|
||||
|
||||
#### Approval Packet
|
||||
|
||||
- 把 agent 的审批请求结构化成移动可处理对象,而不是原始终端 prompt。
|
||||
- 至少包含:
|
||||
- 操作类型
|
||||
- 风险等级
|
||||
- 目标设备 / 会话
|
||||
- 命令摘要或 diff 摘要
|
||||
- 审计字段
|
||||
- `Allow Once`
|
||||
- `Allow For Session`
|
||||
- `Reject`
|
||||
|
||||
#### Diff Review Surface
|
||||
|
||||
- 手机端不直接展示大块原始 patch 文本作为默认视图。
|
||||
- 需要:
|
||||
- 文件级 diff
|
||||
- hunk 摘要
|
||||
- 评论
|
||||
- approve / reject
|
||||
|
||||
#### Quick Actions
|
||||
|
||||
- 把常用动作收敛成可审计按钮:
|
||||
- `Continue`
|
||||
- `Stop Run`
|
||||
- `Re-run Tests`
|
||||
- `Create Draft Commit`
|
||||
- `Open PR Summary`
|
||||
- `Request Desktop Review`
|
||||
|
||||
#### Push + Offline Inbox
|
||||
|
||||
- 以下事件必须可推送:
|
||||
- 任务完成
|
||||
- 等待审批
|
||||
- 等待回答
|
||||
- 失败告警
|
||||
- 离线时进入 inbox,恢复在线后续接。
|
||||
|
||||
#### Presence / Reconnect
|
||||
|
||||
- 允许手机随时 attach 到已有桌面会话。
|
||||
- 不能把手机 attach 视作新的 agent session。
|
||||
|
||||
## Mobile-safe Policy
|
||||
|
||||
`Mobile-safe` 是移动端默认交互策略,不是抽象权限说明。
|
||||
|
||||
### 1. 目标
|
||||
|
||||
- 仅允许结构化审批
|
||||
- 不暴露原始 shell 放权
|
||||
- 优先提供安全、有限、可审计的快捷入口
|
||||
|
||||
### 2. 首发快捷入口
|
||||
|
||||
- `Approve Pairing`
|
||||
- `Reject Pairing`
|
||||
- `View Paired Devices`
|
||||
- `Revoke Paired Device`
|
||||
- `Allow Once`
|
||||
- `Allow For Session`
|
||||
- `Stop Run`
|
||||
- `Request Desktop Review`
|
||||
|
||||
### 3. paired / pairing 入口要求
|
||||
|
||||
- pairing / paired device 操作属于一级入口,不埋在设置深层。
|
||||
- 用户必须能在移动端直接完成:
|
||||
- 查看待审批 pairing
|
||||
- 批准或拒绝 pairing
|
||||
- 查看当前 paired devices
|
||||
- 吊销 paired device
|
||||
|
||||
### 4. 审批展示要求
|
||||
|
||||
- 审批卡必须结构化展示:
|
||||
- 操作类型
|
||||
- 风险等级
|
||||
- 目标设备 / 会话
|
||||
- 命令摘要或 diff 摘要
|
||||
- 审计字段
|
||||
- 默认不显示:
|
||||
- 原始 secret
|
||||
- 原始危险 shell 明文
|
||||
- 不必要的本机路径细节
|
||||
|
||||
### 5. 明确禁止
|
||||
|
||||
- 手机端直接批准“全局 shell access”
|
||||
- 手机端查看或回传 secret 原文
|
||||
- 手机端绕过 `Gateway pairing / device token` 边界
|
||||
|
||||
## Mobile Visual Baseline
|
||||
|
||||
移动端视觉基线遵循 `calm_compact_workspace_system`,目标是在 iOS/Android 上保持与桌面同一家族语义,只按触控和小屏密度做几何调整。
|
||||
|
||||
### 1. 字体
|
||||
|
||||
- iOS 默认:`SF Pro / San Francisco`
|
||||
- Android 默认:`Roboto`
|
||||
- 日志、token、技术值:平台 monospace
|
||||
- 不额外引入自定义 UI 字体
|
||||
|
||||
### 2. 字号与层级
|
||||
|
||||
- 主体默认:`13 / 15 / 400`
|
||||
- 标题与 section header:`13 / 14 / 600`
|
||||
- Caption:`12 / 16 / 400`
|
||||
- Dialog title:`20 / 24 / 600`
|
||||
|
||||
### 3. 风格
|
||||
|
||||
- 保持与桌面同一家族语义,不为移动端单独换品牌
|
||||
- 低噪声、紧凑、柔和层次、弱边框
|
||||
- 用形状、层次、间距表达状态,不靠高饱和颜色
|
||||
- 保持 soft native touch geometry,不引入笨重控件
|
||||
|
||||
### 4. 几何
|
||||
|
||||
- mobile button height:`36`
|
||||
- input height:`40`
|
||||
- card radius:`16`
|
||||
- input radius:`14`
|
||||
- dialog radius:`18`
|
||||
|
||||
### 5. 优先按该基线改写的移动页面
|
||||
|
||||
- `MobileShell`
|
||||
- Gateway / pairing 相关页面
|
||||
- 会话列表
|
||||
- 审批卡片
|
||||
- diff review 卡片
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不在 iOS/Android 本地启动 `Codex/CLI`
|
||||
- 不把手机变成直接访问 repo 的执行器
|
||||
- 不把 Cloud Control Plane 做成手机到 Mac 的强依赖数据面
|
||||
- 不在移动端暴露完整 `SSH/tmux` 风格终端作为主交互
|
||||
- 不为其他 agent provider 先做独立移动直连协议
|
||||
|
||||
## Test Plan
|
||||
|
||||
### 连接与安全
|
||||
|
||||
- 移动端通过 `HTTPS + WSS` 连接 Gateway。
|
||||
- 远程模式不允许静默降级为非 TLS。
|
||||
- 安全回归继续遵守 [secure-development-rules.md](../security/secure-development-rules.md)。
|
||||
|
||||
### pairing / paired 设备
|
||||
|
||||
- 手机首次 pairing 后复连复用同一 device identity 和 device token。
|
||||
- `Approve Pairing` / `Reject Pairing` / `Revoke Paired Device` 在移动端可完成且有审计记录。
|
||||
- paired device 列表可见,且 revoke 不需要退回桌面端完成。
|
||||
|
||||
### 会话与审批
|
||||
|
||||
- 在 Mac 发起会话,手机 attach 后能看到状态、日志摘要、最近 diff、待审批项。
|
||||
- agent 发起审批时,手机仅收到结构化审批卡,不出现原始 secret 或危险 shell 明文。
|
||||
- `Allow Once` 和 `Allow For Session` 行为可区分,桌面端能正确续跑。
|
||||
- 同一会话在桌面和手机间切换,不丢 `session id`、`thread context`、`approval state`。
|
||||
|
||||
### UI 与视觉
|
||||
|
||||
- iOS/Android 默认 typography 与 `calm_compact_workspace_system` 对齐。
|
||||
- 主要页面不出现过大的标题、40px 级笨重控件或混乱字体栈。
|
||||
- 大 diff 时提供文件级折叠和风险摘要,不要求用户在手机上阅读原始巨型 patch。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- 首发目标是“手机安全控制桌面 agent”,不是“手机直接运行 agent”。
|
||||
- `OpenClaw Gateway` 可以演进出 `HTTPS + WSS` 双通道,而不是只停留在现有桌面连接面。
|
||||
- 其他 agent provider 首发不单独建移动直连协议。
|
||||
- `Cloud Control Plane` 不是当前方案主轴,最多保留为后续增强说明。
|
||||
- 本次更新只改计划文档,不执行代码实现。
|
||||
|
||||
## References
|
||||
|
||||
### 本地架构与现状
|
||||
|
||||
- [README.md](../../README.md)
|
||||
- [xworkmate-integrations.md](../architecture/xworkmate-integrations.md)
|
||||
- [gateway-dev-runbook.md](../runbooks/gateway-dev-runbook.md)
|
||||
- [mobile_shell.dart](../../lib/features/mobile/mobile_shell.dart)
|
||||
- [app_controller.dart](../../lib/app/app_controller.dart)
|
||||
- [gateway_runtime.dart](../../lib/runtime/gateway_runtime.dart)
|
||||
|
||||
### 安全与视觉基线
|
||||
|
||||
- [secure-development-rules.md](../security/secure-development-rules.md)
|
||||
- `~/.codex/skills/calm_compact_workspace_system/SKILL.md`
|
||||
|
||||
### 外部证据
|
||||
|
||||
- Codex issue `#2798`
|
||||
- Codex issue `#3052`
|
||||
- Codex issue `#3199`
|
||||
- Codex issue `#4306`
|
||||
- Taskdex
|
||||
- Remote Codetrol
|
||||
File diff suppressed because it is too large
Load Diff
@ -166,21 +166,31 @@ class AppSizes {
|
||||
}
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData light() =>
|
||||
_theme(brightness: Brightness.light, palette: AppPalette.light);
|
||||
static ThemeData light({TargetPlatform? platform}) => _theme(
|
||||
brightness: Brightness.light,
|
||||
palette: AppPalette.light,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
static ThemeData dark() =>
|
||||
_theme(brightness: Brightness.dark, palette: AppPalette.dark);
|
||||
static ThemeData dark({TargetPlatform? platform}) => _theme(
|
||||
brightness: Brightness.dark,
|
||||
palette: AppPalette.dark,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
static ThemeData _theme({
|
||||
required Brightness brightness,
|
||||
required AppPalette palette,
|
||||
TargetPlatform? platform,
|
||||
}) {
|
||||
final platform = defaultTargetPlatform;
|
||||
final resolvedPlatform = platform ?? defaultTargetPlatform;
|
||||
final isDesktop =
|
||||
platform == TargetPlatform.macOS ||
|
||||
platform == TargetPlatform.windows ||
|
||||
platform == TargetPlatform.linux;
|
||||
resolvedPlatform == TargetPlatform.macOS ||
|
||||
resolvedPlatform == TargetPlatform.windows ||
|
||||
resolvedPlatform == TargetPlatform.linux;
|
||||
final isMobile =
|
||||
resolvedPlatform == TargetPlatform.iOS ||
|
||||
resolvedPlatform == TargetPlatform.android;
|
||||
final colorScheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: palette.accent,
|
||||
@ -211,12 +221,16 @@ class AppTheme {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
typography: Typography.material2021(platform: platform),
|
||||
typography: Typography.material2021(platform: resolvedPlatform),
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: palette.canvas,
|
||||
extensions: [palette],
|
||||
);
|
||||
final tunedTextTheme = _textTheme(base.textTheme, palette: palette);
|
||||
final tunedTextTheme = _textTheme(
|
||||
base.textTheme,
|
||||
palette: palette,
|
||||
isMobile: isMobile,
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
@ -428,7 +442,11 @@ class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
static TextTheme _textTheme(TextTheme base, {required AppPalette palette}) {
|
||||
static TextTheme _textTheme(
|
||||
TextTheme base, {
|
||||
required AppPalette palette,
|
||||
required bool isMobile,
|
||||
}) {
|
||||
TextStyle withUiFont(TextStyle? style) {
|
||||
return (style ?? const TextStyle()).copyWith(
|
||||
fontFamily: null,
|
||||
@ -440,10 +458,10 @@ class AppTheme {
|
||||
return base.copyWith(
|
||||
displaySmall: withUiFont(
|
||||
base.displaySmall?.copyWith(
|
||||
fontSize: AppTypography.displaySize,
|
||||
fontSize: isMobile ? 24 : AppTypography.displaySize,
|
||||
fontWeight: AppTypography.displayWeight,
|
||||
letterSpacing: -0.32,
|
||||
height: AppTypography.displayHeight,
|
||||
letterSpacing: isMobile ? -0.24 : -0.32,
|
||||
height: isMobile ? 28 / 24 : AppTypography.displayHeight,
|
||||
color: palette.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@ -102,10 +102,10 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
final horizontalPadding = widget.compact ? 20.0 : 24.0;
|
||||
final verticalPadding = widget.compact ? 18.0 : 22.0;
|
||||
final dialogTitleStyle = theme.textTheme.headlineSmall?.copyWith(
|
||||
fontSize: widget.compact ? 24 : 22,
|
||||
height: widget.compact ? 28 / 24 : 26 / 22,
|
||||
letterSpacing: -0.28,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: AppTypography.titleSize,
|
||||
height: AppTypography.titleHeight,
|
||||
letterSpacing: -0.18,
|
||||
fontWeight: AppTypography.titleWeight,
|
||||
);
|
||||
final supportingCopyStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 12,
|
||||
@ -156,8 +156,8 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
const SizedBox(height: AppSpacing.section),
|
||||
Text(
|
||||
appText(
|
||||
'通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。也可切换到仅 AI Gateway 模式,仅使用模型路由而不建立 Gateway 会话。',
|
||||
'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS. You can also switch to AI Gateway Only mode to use model routing without opening a gateway session.',
|
||||
'通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。远程模式保持显式 TLS 直连;也可切换到仅 AI Gateway 模式,仅使用模型路由而不建立 Gateway 会话。',
|
||||
'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS. Remote mode keeps TLS explicit for direct access. You can also switch to AI Gateway Only mode to use model routing without opening a gateway session.',
|
||||
),
|
||||
style: supportingCopyStyle,
|
||||
),
|
||||
|
||||
@ -27,8 +27,8 @@ void main() {
|
||||
locale: const Locale('zh'),
|
||||
supportedLocales: const [Locale('zh'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: AppTheme.light().copyWith(platform: platform),
|
||||
darkTheme: AppTheme.dark().copyWith(platform: platform),
|
||||
theme: AppTheme.light(platform: platform),
|
||||
darkTheme: AppTheme.dark(platform: platform),
|
||||
home: child,
|
||||
),
|
||||
);
|
||||
@ -168,8 +168,8 @@ void main() {
|
||||
locale: const Locale('zh'),
|
||||
supportedLocales: const [Locale('zh'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: AppTheme.light().copyWith(platform: TargetPlatform.android),
|
||||
darkTheme: AppTheme.dark().copyWith(platform: TargetPlatform.android),
|
||||
theme: AppTheme.light(platform: TargetPlatform.android),
|
||||
darkTheme: AppTheme.dark(platform: TargetPlatform.android),
|
||||
home: AppShell(controller: desktopAndroidController),
|
||||
),
|
||||
);
|
||||
@ -177,4 +177,23 @@ void main() {
|
||||
|
||||
expect(find.byType(MobileShell), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('MobileShell exposes mobile-safe pairing shortcuts', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpMobileShell(
|
||||
tester,
|
||||
child: MobileShell(controller: controller),
|
||||
platform: TargetPlatform.iOS,
|
||||
);
|
||||
|
||||
expect(find.byKey(const ValueKey('mobile-safe-strip')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('mobile-safe-open-button')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('mobile-safe-connect-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -47,10 +47,10 @@ Future<void> pumpPage(
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: platform == null
|
||||
? AppTheme.light()
|
||||
: AppTheme.light().copyWith(platform: platform),
|
||||
: AppTheme.light(platform: platform),
|
||||
darkTheme: platform == null
|
||||
? AppTheme.dark()
|
||||
: AppTheme.dark().copyWith(platform: platform),
|
||||
: AppTheme.dark(platform: platform),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
|
||||
36
test/theme/app_theme_test.dart
Normal file
36
test/theme/app_theme_test.dart
Normal file
@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
test('AppTheme uses compact mobile typography on iOS and Android', () {
|
||||
final iosTheme = AppTheme.light(platform: TargetPlatform.iOS);
|
||||
final androidTheme = AppTheme.light(platform: TargetPlatform.android);
|
||||
|
||||
expect(iosTheme.textTheme.displaySmall?.fontSize, 24);
|
||||
expect(androidTheme.textTheme.displaySmall?.fontSize, 24);
|
||||
expect(iosTheme.textTheme.headlineSmall?.fontSize, AppTypography.titleSize);
|
||||
expect(
|
||||
androidTheme.textTheme.headlineSmall?.fontSize,
|
||||
AppTypography.titleSize,
|
||||
);
|
||||
expect(
|
||||
iosTheme.filledButtonTheme.style?.minimumSize?.resolve({})?.height,
|
||||
AppSizes.buttonHeightMobile,
|
||||
);
|
||||
expect(
|
||||
androidTheme.inputDecorationTheme.constraints?.minHeight,
|
||||
AppSizes.inputHeight,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppTheme keeps larger display typography on desktop surfaces', () {
|
||||
final desktopTheme = AppTheme.light(platform: TargetPlatform.macOS);
|
||||
|
||||
expect(desktopTheme.textTheme.displaySmall?.fontSize, 28);
|
||||
expect(
|
||||
desktopTheme.filledButtonTheme.style?.minimumSize?.resolve({})?.height,
|
||||
AppSizes.buttonHeightDesktop,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user