diff --git a/.hermes/README.md b/.hermes/README.md index e0856e7..517718e 100644 --- a/.hermes/README.md +++ b/.hermes/README.md @@ -1,162 +1,36 @@ -# Hermes Agent 项目架构记录 +# Flutter Forge Agent 入口 -> flutter_forge_workspace Hermes Agent 托管架构 — 完全托管模式 -> 里程碑: online_video_player_landed | 阶段: agent_managed | 更新: 2026-08-02 +本目录只保存任务级 Agent 输入;项目规则与事实不在此重复维护。 -## 项目概述 +## 阅读顺序 -flutter_forge_workspace 是一个 Flutter 模块化学习工作区,涵盖基础机制、异步并发、状态管理、UI 动效、弹窗列表、网络平台六大分类共 18 个学习模块。通过 Dart Pub Workspace 管理 4 个内部共享包。 +1. `AGENTS.md`:行为、修改、Git 与发布规则。 +2. `AI_PROJECT_CONTEXT.md`:架构、入口、平台和验证契约。 +3. `REFACTOR_PLAN.md`:当前任务队列与验收条件。 +4. 目标目录最近的 `AI_ANALYSIS.md`:局部所有权与依赖。 +5. 平台或导航任务再读 `CONTEXT.md` 和相关 `docs/adr/`。 -## Agent 文档体系 +## 项目模型 -``` -flutter_forge_workspace/ -├── AGENTS.md # Agent 行为契约(入口) -├── AI_ANALYSIS_SCHEMA.json # 文档 schema 定义 -├── AI_PROJECT_CONTEXT.md # 项目架构上下文(JSON 机器契约) -├── REFACTOR_PLAN.md # 任务队列与里程碑 -├── AI_ANALYSIS.md # 工作区根索引 -├── lib/ -│ ├── AI_ANALYSIS.md # lib 层索引 -│ ├── AI_MODULE_INDEX.md # 模块索引(生成物) -│ ├── app/AI_ANALYSIS.md # 应用壳层 -│ ├── app/router/AI_ANALYSIS.md # 路由层 -│ ├── module_registry/AI_ANALYSIS.md # 模块注册表 -│ ├── shared/AI_ANALYSIS.md # 共享层 -│ ├── modules/AI_ANALYSIS.md # 模块根索引 -│ └── modules/{category}/{module}/AI_ANALYSIS.md # 18个模块契约 -├── packages/ -│ ├── gcode_core/AI_ANALYSIS.md # G-code 解析包 -│ ├── flutter_study_learning/AI_ANALYSIS.md # 教学模板包 -│ ├── file_picker_bridge/AI_ANALYSIS.md # 文件选择桥接 -│ └── flutter_ioc_core/AI_ANALYSIS.md # IoC 容器 -├── tool/ -│ ├── generate_agent_indexes.js # Agent 文档生成器(唯一生成源) -│ ├── validate_agent_docs.js # Agent 文档校验器 -│ ├── generate_harness_ai_analysis.sh # 生成+校验入口 -│ ├── quality_gate.sh # 全量质量门禁(统一入口) -│ ├── bootstrap.sh # 环境自举 -│ ├── check_environment.sh # 环境检查 -│ └── test_all.sh # 全量测试 -├── docs/ -│ ├── DEVELOPMENT.md # 开发指南 -│ ├── TESTING.md # 测试指南 -│ ├── agent/ -│ │ ├── TASK_SCHEMA.json # Agent 任务输入格式 -│ │ ├── CHANGE_REPORT_SCHEMA.json # Agent 变更报告格式 -│ │ └── COMMANDS.json # 项目命令清单 -│ └── adr/ -│ ├── README.md # ADR 索引 -│ ├── 0001-repository-layout.md # 单仓布局决策 -│ └── 0002-agent-contract-source-of-truth.md # 契约生成源决策 -├── .github/workflows/ci.yml # CI 流水线(FlutterGuard 固定版本) -├── .fvmrc # Flutter 3.44.6 -├── .nvmrc # Node 20.20.2 -└── .hermes/ - ├── README.md # 本文档 - └── .codex.json # 单次任务 JSON 提词(Codex 执行依据) -``` - -总计: 37 个 lib AI_ANALYSIS.md + 4 个 packages AI_ANALYSIS.md = 41 个验证通过。 +```text +apps/flutter_forge/lib/ +├── app/ 启动、应用壳、路由、NavigationPolicy +├── module_registry/ 模块元数据、平台快照、目录判定 +├── shared/ 教学模板和业务无关能力 +└── modules/ 六类学习模块;模块之间禁止直接依赖 -## 分层架构 - -``` -app/ ← 宿主引导 + 路由组装(禁止被 modules/ 依赖) -module_registry/ ← 模块元数据与分类(仅依赖 Flutter + go_router) -shared/ ← 业务无关能力(禁止依赖 app/ 和 modules/) -modules/ ← 学习模块叶子节点(禁止互相依赖) -packages/ ← 工作区共享包(Dart Pub Workspace,独立可测) +packages/ Pub Workspace 内部能力包 +tool/ 契约生成、测试和质量门禁 ``` -## 依赖方向 - -``` -app → module_registry, shared, modules -shared → 仅 Flutter SDK -modules → module_registry, flutter_study_learning, packages/* -packages/* → 仅 Flutter/Dart SDK(独立包,workspace 内互不可见) -``` - -## 质量门禁(单一入口) - -```bash -bash tool/quality_gate.sh -``` - -内部 5 阶段: -1. Agent 文档生成 + 校验 + 漂移检测 (41 contracts) -2. dart format + git diff (格式不漂移) -3. flutter analyze (0 errors) -4. test_all.sh (5/5 packages) -5. flutterguard --fail-on high (0 HIGH) - -## 当前基线 (2026-08-02 — online_video_player_landed) - -| 项目 | 状态 | -|------|------| -| Agent 文档 | ✅ 41 契约验证通过,生成源已修正 | -| Pub Workspace | ✅ 4 包,resolution_status=active | -| dart format | ✅ 0 changed | -| flutter analyze | ✅ 0 errors, 198 info | -| flutterguard | ✅ 0 HIGH, 5 MEDIUM(既有) | -| 测试 | ✅ 5/5 通过(含 online_video_player 3 用例) | -| CI | ✅ .github/workflows/ci.yml 已配置(FlutterGuard 固定版本) | -| 工具链锁定 | ✅ .fvmrc (Flutter 3.44.6), .nvmrc (Node 20.20.2) | -| 质量门禁脚本 | ✅ quality_gate.sh, bootstrap.sh, test_all.sh, check_environment.sh | -| 人类文档 | ✅ CONTRIBUTING.md, docs/DEVELOPMENT.md, docs/TESTING.md | -| Agent 协议 | ✅ TASK_SCHEMA.json, CHANGE_REPORT_SCHEMA.json, COMMANDS.json | -| ADR | ✅ 0001-repository-layout, 0002-agent-contract-source-of-truth | -| 托管模式 | ✅ agent_managed (REFACTOR_PLAN.active_phase) | -| 在线视频播放模块 | ✅ lib/modules/platform/online_video_player(media_kit,3 测试全过) | - -## 已完成的里程碑 - -1. directory_layers — 分层目录结构 -2. shared_package_extraction — 共享包提取 -3. module_analysis_coverage — 模块分析覆盖 -4. app_navigation_boundary — 应用导航边界 -5. host_bootstrap_boundary — 宿主引导边界 -6. workspace_package_import — 工作区包导入 -7. agent_takeover_ready — Agent 完全托管就绪 -8. online_video_player_landed — 在线视频播放模块落地(media_kit,macOS 优先)← 当前 - -## 最近进度(2026-07-25 → 2026-08-02) - -| 日期 | 事项 | -|------|------| -| 2026-07-25 | quality_gate 5/5 通过,agent_takeover_ready 达成 | -| 2026-08-02 | 修复 tool/test_agent_tools.sh 用例(19/19 全绿) | -| 2026-08-02 | 新增「在线视频播放」模块:JSON 提词 → Codex 落地 → Hermes 验收 | -| 2026-08-02 | media_kit macOS 集成:entitlements 补 network.client、ensureInitialized 时序 | -| 2026-08-02 | 解决 libmpv xcframework 下载不可达(ghfast 镜像 + SHA256 校验) | -| 2026-08-02 | flutter build macos --debug 成功,quality_gate 5/5 通过 | -| 2026-08-02 | Notion 指导文档审查修正 + 配套实操示例页 | - -## 待推进 (P1-P2) - -| 项目 | 优先级 | 备注 | -|------|--------|------| -| Android 平台适配 (module_platform_contract → android_host) | P1 | REFACTOR_PLAN 中 blocked_by_dependencies | -| mobile_layout_baseline(移动端布局基线) | P1 | REFACTOR_PLAN 中 pending | -| platform_plugin_audit(平台插件审计) | P2 | REFACTOR_PLAN 中 pending | -| FlutterGuard MEDIUM 消减 (5 issues) | P2 | 既有问题,非本模块引入 | -| 教学页视觉证据(截图/golden) | P2 | 人工验收依赖 | - -## 后续演进方向 +模块、路由、平台限制和 Agent 契约统一从 `tool/generate_agent_indexes.js` 生成。不要手改生成文件,也不要在本文件记录模块数量、版本、历史进度或测试快照。 -1. **平台扩展**:在线视频播放模块当前 macOS 优先;后续按 REFACTOR_PLAN 推进 android_host,media_kit 三件套已支持 Android,预计补充网络权限声明与真机验证即可复用。 -2. **多模块模式沉淀**:online_video_player 已验证「JSON 提词 → Codex → 独立验收」闭环,可作为新平台/新模块的标准执行范式。 -3. **CI 强化**:ci.yml 已固定 FlutterGuard 版本;后续可补充 macOS 构建 job(media_kit 原生依赖需可下载的 CI 环境)。 -4. **构建环境注意项**:libmpv 依赖 GitHub releases 下载;若网络受限需走镜像(ghfast.top),SHA256 校验 84d2ad98... 已固化在 pub-cache 缓存。 -5. **Notion 文档体系**:指导文档 + 实操示例页已关联,后续每个新模块可在实操页追加一节约 1 屏的落地记录。 +## 执行约定 -## Agent 执行约定 +- 开始前检查 `git status`,不覆盖无关改动。 +- 任务范围以用户请求和任务 JSON 为准,不自行扩大权限。 +- 修改模块前读取模块契约;结构变化同时更新生成源。 +- 验证统一执行 `bash tool/quality_gate.sh`。 +- 未经明确授权,不提交、不推送、不合并。 -1. 修改代码前: read AGENTS.md + .hermes/README.md + 目标 AI_ANALYSIS.md -2. 修改生成源: 编辑 tool/generate_agent_indexes.js,不要手改生成物 -3. 验证: bash tool/generate_harness_ai_analysis.sh -4. 门禁: bash tool/quality_gate.sh -5. 禁止: 提交/推送/合并(除非用户明确授权) -6. 禁止: 手改 AI_MODULE_INDEX.md / AI_PROJECT_CONTEXT.md / REFACTOR_PLAN.md / packages/*/AI_ANALYSIS.md -7. 任务提词: 写入 .hermes/.codex.json(schema: flutter_forge.agent_task.v1),Codex 只执行提词,Hermes 独立验收 +任务 JSON 如存在,放在 `.hermes/.codex.json`;它描述单次任务,不替代 `AGENTS.md` 或机器契约。 diff --git a/AGENTS.md b/AGENTS.md index 729ec97..8bac47a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,148 +1,72 @@ -# AGENTS.md - Agent 维护契约 +# AGENTS.md — Agent 开发规则 -> 本文档约束所有 AI agent 对本项目的修改行为。修改代码前必须阅读。 +本文件是项目级 Agent 入口。以可执行规则为主;项目事实以机器契约和代码为准。 -## 前置阅读 +## 修改前必读 -执行任何修改前,agent 必须读取: -1. `AI_ANALYSIS_SCHEMA.json` - agent 文档 schema -2. `AI_PROJECT_CONTEXT.md` - 机器可解析项目契约 -3. `REFACTOR_PLAN.md` - 机器可解析任务队列 -4. 目标模块的 `AI_ANALYSIS.md` - 机器可解析模块契约 +1. `AI_ANALYSIS_SCHEMA.json` +2. `AI_PROJECT_CONTEXT.md` +3. `REFACTOR_PLAN.md` +4. 目标目录最近的 `AI_ANALYSIS.md` -涉及平台、导航拓扑或模块准入时,同时读取 `CONTEXT.md` 和相关 `docs/adr/`。 +涉及平台、导航、模块准入或发布时,再读 `CONTEXT.md` 与相关 `docs/adr/`。`AI_*.md` 和 `REFACTOR_PLAN.md` 是 JSON 机器契约,禁止加入 Markdown 段落。 -以上 agent 文档均为 JSON,禁止加入 Markdown、自然语言段落或手工文件清单。 +## 事实源与边界 -## 新增模块规则 +- 模块、路由、平台限制及 Agent 文档的生成源:`tool/generate_agent_indexes.js`。 +- 生成物禁止手改;修改生成源后运行 `bash tool/generate_harness_ai_analysis.sh`。 +- `app/` 负责启动、应用壳和导航;`module_registry/` 负责模块元数据与平台判定。 +- `shared/` 只放业务无关能力,不依赖 `app/` 或 `modules/`。 +- `modules/` 是学习模块叶子节点,模块之间禁止直接依赖。 +- 工作区内部包位于 `packages/`;禁止引用仓库外 `path: ../...` 依赖。 -新模块 **必须** 包含以下内容,否则视为不合规: +## 模块规则 -| 必需项 | 说明 | -|--------|------| -| `module_entry.dart` | 导出 `*Entry` Widget,作为模块入口 | -| `AI_ANALYSIS.md` | 模块机器契约:route、category、status、entrypoints、owns、depends、analysis_parent、validation | -| 路由注册 | 在 `apps/flutter_forge/lib/app/router/app_route_table.dart` 的 `_modules` 中注册 | -| 模块元数据 | `ModuleEntry` 必须填写 `category`、`difficulty`、`concepts`、`estimatedMinutes`、`status`、`subtitle` | -| 教学页面 | 至少 1 个页面使用 `lib/shared/learning` 中的教学模板组件(`LearningScaffold` 等) | +新增模块必须同时具备: -平台可用性规则: +- `module_entry.dart`,导出 `*Entry` Widget。 +- `AI_ANALYSIS.md` 模块契约。 +- 生成器中的模块注册、路由和完整 `ModuleEntry` 元数据。 +- 至少一个 `lib/shared/learning` 教学模板组件。 +- 对应模块测试;涉及状态、异步或平台能力时补行为测试。 -- `ModuleEntry.supportedPlatforms == null` 表示模块默认支持所有平台。 -- 平台相关模块必须显式填写 `supportedPlatforms`,并在字段旁保留语义清晰的注释。 -- 平台判定必须通过 `module_registry/module_catalog_utils.dart` 集中执行,模块页面不得自行调用 `Platform` API 决定目录可见性。 -- 不支持的模块保留在目录中,显示不可用状态;其模块路由不注册。 -- 平台可用性与 `ModuleStatus`(学习质量状态)分离,不能用 `pending/ready/recommended` 表达平台限制。 +标题使用中文学习语义,禁止只有工程目录名或无解释、无交互的孤立 Demo。 -导航拓扑规则: +## 平台与导航 -- Android、iOS 和 Web 永远使用应用内导航,不创建业务多窗口。 -- 小于 `600dp` 的紧凑窗口使用应用内导航。 -- 只有支持多窗口的桌面平台且窗口宽度不小于 `600dp` 时,才允许创建分类窗口。 -- UI 必须通过 `NavigationPolicy` 获取导航模式,不能直接用 `Platform.isMacOS` 等条件决定业务导航。 -- `MultiWindowManager` 只负责桌面窗口生命周期,不负责移动端导航策略。 +- 平台限制用 `ModulePlatformSupport.excludedPlatforms` 表达;普通模块使用空排除集合。 +- 平台模块必须在生成源中显式声明排除集合并说明依据。 +- 目录和路由统一通过 `module_registry` 判定;模块页面不得自行决定目录可见性。 +- 不支持的平台保留稳定模块路径,并进入统一不可用说明页。 +- 平台可用性与 `ModuleStatus.pending/ready/recommended` 相互独立。 +- Android、iOS、Web 和小于 `600dp` 的窗口使用应用内导航。 +- 仅桌面宽窗口且多窗口能力可用时创建分类窗口。 +- UI 通过 `NavigationPolicy` 选择导航模式;`MultiWindowManager` 只管理桌面窗口生命周期。 +- 平台接入、构建通过和真机/真实主机验收必须分开描述;不得扩大证据范围。 -## 初版封板与业务准入 +## 修改流程 -- 当前封板目标是 PC 基线(macOS/Windows);Android 是非阻塞兼容轨道,不得被描述为已完成的全平台封板。 -- 在 PC 封板完成前,禁止新增依赖多窗口、原生插件或复杂跨平台状态的新业务模块。 -- PC 封板至少需要三分类窗口创建、重复打开复用、关闭后重开、无黑屏、无 `Invalid engine handle`,以及 macOS/Windows 构建证据。 -- Windows 构建必须在 Windows CI 或 Windows 主机完成;macOS 主机上的拒绝结果不能作为 Windows 通过证据。 -- Android 业务必须保持单窗口;Android 平台能力未完成时,模块必须声明平台限制并提供明确不可用状态。 -- 新业务模块必须通过 Agent Hub 冻结任务、模块契约、Tier 1 测试;涉及状态、异步或平台能力时必须补充行为测试。 -- 模块脚手架属于封板后的效率建设,不作为当前封板前置条件。 +1. 检查 `git status`,保留用户已有改动。 +2. 读取目标契约和代码,修改最小必要范围。 +3. 模块、依赖、路由、层级或平台声明变化时,同步更新生成源。 +4. 逻辑变化补定向测试;教学 UI 变化补人工验收或截图说明。 +5. 执行 `bash tool/quality_gate.sh`。 -## 修改模块规则 +质量门禁必须通过:Agent 文档无漂移、Dart 格式无漂移、bare `flutter analyze` 无 issue、全量测试通过、测试布局合规、FlutterGuard 无 HIGH。 -1. 修改前先读取该模块的 `AI_ANALYSIS.md` -2. 修改模块、依赖、路由或层级时,更新 `tool/generate_agent_indexes.js` 中的生成源 -3. 执行 `bash tool/generate_harness_ai_analysis.sh` 重新生成并校验 agent 文档 -4. 如果修改了路由注册,同步更新元数据字段 +## Git 与发布 -## 验收规则 +- `dev` 是开发分支;功能、修复、文档和发版准备先进入 `dev`。 +- `master` 只能通过从 `dev` 发起的 Pull Request 合入;禁止直接或强制推送。 +- 未经用户明确授权,不提交、不推送、不合并,不处理无关工作树改动。 +- 禁止修改 `.github/workflows/ci.yml`、`tool/quality_gate.sh` 或其他门禁脚本语义,除非任务显式授权 `packaging_change` 并要求人工验收。 +- 业务仓库 CI 只能构建暂存产物;GitHub Release 必须由 Agent Hub `release_hosting` 的 `release-plan` / `release-run --execute` 流程执行。 -每次代码修改后 **必须** 执行: -```bash -bash tool/quality_gate.sh -``` - -等效手动步骤(quality_gate.sh 内部执行顺序): -1. `bash tool/generate_harness_ai_analysis.sh` + `git diff --exit-code` (文档不漂移) -2. `dart format .` + `git diff --exit-code -- '*.dart'` (格式不漂移) -3. `flutter analyze` (bare:info/warning 同样视为失败) -4. `bash tool/test_all.sh` (全部测试通过) -5. `bash tool/verify_test_layout.sh` (测试目录布局合规并输出模块测试覆盖报告) -6. `cd apps/flutter_forge && dart run flutterguard_cli:flutterguard scan . --fail-on high` (无 HIGH 问题,仅本地执行) - -- `flutter analyze` 必须通过(bare,不允许任何 issue,含 info) -- `flutterguard scan --fail-on high` 必须通过,不允许引入高优问题(仅本地;CI 不运行 flutterguard) -- 远端打包门禁为 `.github/workflows/ci.yml`,同样使用 bare `flutter analyze`,是唯一权威的远端验收 -- 涉及逻辑代码时补充测试 -- 涉及 UI 教学页时进行人工验收或截图说明 - -## 禁止事项 - -| 禁止 | 说明 | -|------|------| -| 孤立 demo | 禁止新增无解释、无交互的粗糙 demo 页面 | -| 纯工程名 | 禁止首页出现纯工程目录名(如 `tree_state`),必须使用中文学习语义标题 | -| 跳过分析文档 | 禁止修改模块后不更新 `AI_ANALYSIS.md` | -| 绕过分析 | 禁止绕过 `flutter analyze` 直接提交 | -| 破坏元数据 | 禁止注册 `ModuleEntry` 时省略 `subtitle`、`category`、`difficulty` 等字段 | -| 修改打包门禁 | 禁止修改 `.github/workflows/ci.yml`、`tool/quality_gate.sh` 及其余 `tool/*.sh` 门禁脚本的语义,除非任务显式声明并经人工验收(agent-hub 侧同样受 `packaging_change` 保护路径守卫约束)。`.github/workflows/release.yml` 是首个安装器发布任务明确授权的打包工作流;后续修改仍需显式 `packaging_change` 任务和人工验收。 | - -发布工作流规则:业务仓库 CI 只能构建并上传暂存产物,禁止直接创建、编辑或上传 GitHub Release。所有发布必须由 Agent Hub 的 `release_hosting` 图通过 `release-plan` 冻结 `ReleaseProgram`,再由 `release-run --execute` 执行;绕过 Agent Hub 的发布入口视为不合规。 - -## Git 分支规则 - -- `dev` 是持续开发与日常推送分支,所有功能、修复、文档和发版准备改动先进入 `dev`。 -- `master` 是受保护的稳定分支,禁止直接推送、强制推送或删除,只能通过从 `dev` 发起的 Pull Request 合入。 -- 合入 `master` 前必须通过仓库 CI 的 `quality-gate`,所有 review thread 必须解决。当前仓库只有单一管理员,强制批准数为 0,避免 PR 作者无法自审形成死锁;新增具备 write 权限的维护者后再恢复至少 1 次批准。 -- `master` 合入后,应将其合并拓扑同步回 `dev`,避免 GitHub 因 PR merge commit 显示 `master` 虚假领先;不得通过重写 `dev` 历史处理该差异。 -- Agent 不得使用管理员绕过权限直接更新 `master`。标签与 Release 仍遵循 Agent Hub `release_hosting` 规则。 - -## Harless 巡检职责 - -定期执行以下检查: - -1. 扫描 `apps/flutter_forge/lib/modules/` 下所有模块目录,检查是否都在 `_modules` 中注册 -2. 检查每个模块是否有 `AI_ANALYSIS.md` -3. 检查重点模块是否使用教学模板(`lib/shared/learning`) -4. 检查 `ModuleEntry` 元数据是否完整(所有必填字段) -5. 标记低质量模块的 `status` 为 `ModuleStatus.pending` -6. 检查 `flutter analyze` 和 `dart format` 是否通过 - -## 启用预提交钩子 +## 常用命令 ```bash +bash tool/generate_harness_ai_analysis.sh +bash tool/quality_gate.sh +bash tool/build_web_release.sh git config core.hooksPath .githooks ``` - -这会在每次 `git commit` 前自动执行 FlutterGuard 扫描,阻止引入高优问题的提交。 - -## 模块分类枚举 - -```dart -ModuleCategory.basic // 基础机制 -ModuleCategory.async // 异步并发 -ModuleCategory.state // 状态管理 -ModuleCategory.ui // UI 与动效 -ModuleCategory.popupTable // 弹窗与列表 -ModuleCategory.platform // 网络与平台 -``` - -## 难度等级枚举 - -```dart -Difficulty.beginner // 入门 -Difficulty.intermediate // 进阶 -Difficulty.advanced // 实战 -``` - -## 模块状态枚举 - -```dart -ModuleStatus.pending // 待整改 -ModuleStatus.ready // 可学习 -ModuleStatus.recommended // 推荐 -``` diff --git a/AI_ANALYSIS_SCHEMA.json b/AI_ANALYSIS_SCHEMA.json index fd09856..43dc800 100644 --- a/AI_ANALYSIS_SCHEMA.json +++ b/AI_ANALYSIS_SCHEMA.json @@ -70,8 +70,7 @@ "route", "category", "status", - "supported_platforms", - "supports_web", + "platform_support", "entrypoints", "analysis_parent" ], diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index 29eaa5b..20551f3 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -17,6 +17,7 @@ "next_host": "web", "target_hosts": [ "android", + "ios", "macos", "web", "windows" @@ -27,7 +28,8 @@ "bootstrap": "lib/app/app_bootstrap.dart", "app": "lib/app/app.dart", "router": "lib/app/router/app_router.dart", - "route_table": "lib/app/router/app_route_table.dart" + "route_table": "lib/app/router/app_route_table.dart", + "module_manifest": "lib/module_registry/module_manifest.dart" }, "repository": { "layout": "pub_workspace", @@ -109,6 +111,7 @@ "path": "lib/module_registry", "owns": [ "module_metadata", + "platform_snapshot", "catalog_operations" ], "may_depend_on": [ @@ -147,7 +150,7 @@ "module_entry.dart", "AI_ANALYSIS.md" ], - "required_registration": "lib/app/router/app_route_table.dart", + "required_registration": "lib/module_registry/module_manifest.dart", "required_metadata": [ "category", "difficulty", @@ -168,7 +171,17 @@ "compact_width_breakpoint_dp": 600, "mobile_window_policy": "in_app_navigation_only", "web_window_policy": "in_app_navigation_only", - "web_platform_detection": "kIsWeb_before_defaultTargetPlatform", + "platform_snapshot": "bootstrap_initialized_process_immutable", + "target_platforms": [ + "android", + "ios", + "macos", + "web", + "windows" + ], + "non_target_platforms": "unsupported", + "module_platform_semantics": "target_platform_available_unless_excluded", + "unsupported_route": "stable_guarded_module_route", "web_release_build": "bash tool/build_web_release.sh", "web_startup_shell": "web/index.html + web/flutter_bootstrap.js", "platform_capability_contract": "business_neutral_interface" diff --git a/CONTEXT.md b/CONTEXT.md index 5ccd3b6..06906c7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,14 +16,38 @@ _Avoid_: Android 封板 一个具有学习入口、课程元数据、教学内容和独立测试的可注册学习单元。 _Avoid_: demo、页面示例 -**分类窗口**: -桌面大屏上承载一个学习分类的独立窗口;它属于导航基础设施,不属于业务模块。 -_Avoid_: 业务窗口、模块窗口 +**自适应导航外壳**: +桌面端与移动端共享信息架构和路由语义,但根据可用空间采用不同导航呈现的应用框架。 +_Avoid_: 两套产品、等比缩放布局、最小差异 + +**主窗口侧栏**: +桌面主窗口中用于总览、分类发现和模块搜索的可折叠导航区域;移动端按需呈现其等价入口。 +_Avoid_: 模块树、业务菜单、移动端常驻侧栏 + +**分类专注窗口**: +桌面端由使用者显式打开、只承载一个学习分类的独立窗口;它不重复主窗口侧栏,属于导航基础设施而非业务模块。 +_Avoid_: 分类窗口、业务窗口、模块窗口 **模块准入**: 新业务模块进入主分支前必须满足模块契约、测试和 Agent Hub 路径治理要求。 _Avoid_: 直接注册、手工接入 +**平台支持**: +模块在目标平台已完成后端接入、插件注册、路由开放、自动化测试和目标平台构建,可以向使用者开放;它不等同于已取得真机或真实主机证据。 +_Avoid_: 平台验收、仅能编译 + +**产品目标平台**: +平台判定模型覆盖 Android、iOS、macOS、Web 和 Windows;进入目标集合只表示契约可以判定该平台,不证明对应宿主已经建立或通过验收。 +_Avoid_: Flutter 全平台、宿主已完成 + +**平台限制**: +模块契约中明确排除的产品目标平台集合;目标集合内未被排除的平台视为支持,Linux 与 Fuchsia 等目标外平台一律不支持。 +_Avoid_: 支持平台清单、插件能力自动推断 + +**平台验收**: +模块在指定模拟器、真机或真实主机上取得的运行证据;它用于记录交付可信度,不形成额外的运行时支持状态。 +_Avoid_: 平台支持、接入完成 + **内嵌网页模块**: 在学习页面内展示网页、提供网页导航与加载反馈的学习单元;由应用统一管理入口与平台可用性。 _Avoid_: 内置浏览器应用、webview_flutter 模块、业务窗口 @@ -48,6 +72,26 @@ _Avoid_: 单浏览器通过、全浏览器已支持 以单个可交互长方体讲解 Scene、Node、Mesh、Camera 与 Flutter GPU 的最小实践;首期不包含模型加载、物理、定制着色器、后处理、场景编辑和高级材质。 _Avoid_: 3D 编辑器、完整渲染框架 +**教学型 3D 查看器**: +以真实的 360 度商品或设备查看流程承载相机动效教学;首阶段只深化单个长方体的相机环绕、缩放、暂停与复位,不扩展到模型加载或场景编辑。 +_Avoid_: 3D 商品平台、模型编辑器、通用 Viewer SDK + +**直接观察操控**: +使用者通过鼠标或键盘接管受限的双轴轨道相机;接管后自动巡展保持停止,直至使用者明确继续或重置。 +_Avoid_: 自由飞行相机、操作结束后强制恢复巡展 + +**低动态观察模式**: +遵从系统减少动态效果偏好,不主动播放持续巡展或惯性运动,同时保留观察对象所需的直接控制。 +_Avoid_: 禁用全部交互、仅隐藏动画控件 + +**3D 部件选取**: +使用者从当前可见部件中选择最接近视线的命中项,并可将观察镜头聚焦到该部件;环绕拖拽只改变观察方向,不产生选择。 +_Avoid_: 场景编辑、物理碰撞查询、拖拽即选择 + **GPU 平台准入**: -主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 首期只准入 macOS,Windows、移动端与 Web 必须保持目录可见但不可用,直至各自主机验收完成。 +主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 已逻辑准入 macOS、Windows 与 Android,其中 Android 首阶段仅提供自动巡展的只读查看,Windows 与 Android 仍需各自主机完成构建和 GPU 首帧验收,iOS 与 Web 保持目录可见但不可用。 _Avoid_: 配置即支持、包声明即支持 + +**Android 3D 只读查看**: +Android 首阶段只呈现场景和自动巡展,不提供手势、键盘、缩略图复位、部件选取或镜头聚焦;它是后续移动操控建设前的渲染准入阶段。 +_Avoid_: Android 3D 编辑器、移动端完整操控 diff --git a/README.md b/README.md index fa01c0c..6711961 100644 --- a/README.md +++ b/README.md @@ -1,212 +1,186 @@ -# Flutter 学习实验室 - -本项目是面向 Flutter 初学者的学习项目集合,所有学习模块位于 `apps/flutter_forge/lib/modules/` 目录下,按主题分类,通过路由切换进入不同模块。项目同时维护 `apps/flutter_forge/lib/app/` 应用壳、`apps/flutter_forge/lib/module_registry/` 模块元数据和 `apps/flutter_forge/lib/shared/` 共享能力,让示例既能独立学习,也能按真实工程方式演进。 - -在线体验:[Flutter Forge Web](https://lizy-coding.github.io/flutter_forge/) - -![demo](https://raw.githubusercontent.com/lizy-coding/flutter_forge/master/assets/demo.gif) - -Full video: https://github.com/user-attachments/assets/6af279c0-7d82-42bc-81b1-624071b0e2ea - -## 推荐学习顺序 - -1. **三棵树与生命周期** - 理解 Flutter 渲染基础 -2. **事件循环与微任务** - 掌握异步执行顺序 -3. **Stream 订阅机制** - 学习流式数据处理 -4. **Isolate 并发** - 理解多线程与性能优化 -5. **状态管理演进** - 对比 Provider/Riverpod/Bloc -6. **UI 动效与绘制** - 学习动画与自定义绘制 -7. **网络与平台能力** - 实战拦截器、USB 设备等 - -## 支持平台 - -- macOS -- Windows -- 移动端(iOS / Android) - -平台相关模块通过注册表中的 `supportedPlatforms` 判定可用性。未支持当前平台的模块仍显示在目录中,但会标记为不可用且不会注册可进入的路由;未填写该字段的模块默认支持所有平台。 - -分类导航采用响应式策略:Android、iOS 和 Web 使用应用内导航;小于 `600dp` 的紧凑窗口使用应用内导航;只有支持多窗口的桌面大窗口才创建独立分类窗口。 - -## 环境与快速开始 - -- Flutter 3.x / Dart 3.x -- 应用目录执行: - ```bash - cd apps/flutter_forge - flutter pub get - flutter run [-d ] - ``` -- 代码检查与格式化: - ```bash - flutter analyze - dart format . - ``` -- 标准验收(全量质量门禁): - ```bash - bash tool/quality_gate.sh - ``` - 分层验收标准与自动化计划见 `docs/QUALITY_ACCEPTANCE.md`。 - -## 项目结构 - -``` -apps/flutter_forge/lib/ -├── main.dart # 入口 -├── app/ # 应用壳 -│ ├── app.dart # MaterialApp.router -│ └── router/ # go_router 路由配置 -├── module_registry/ # 模块元数据(入口模型、枚举) -├── shared/ # 共享能力迁移后的边界文档与过渡层 -│ ├── AI_ANALYSIS.md -│ └── platform/AI_ANALYSIS.md -└── modules/ # 学习模块分区 - ├── basic/ # 基础机制 - ├── async/ # 异步并发 - ├── state/ # 状态管理 - ├── ui/ # UI 与动效 - ├── popup_table/ # 弹窗与列表 - └── platform/ # 网络与平台 -``` - -同级能力包: - -``` -apps/flutter_forge/lib/shared/learning # 应用内教学模板组件 -packages/file_picker_bridge # 文件选择 Dart API / MethodChannel client -packages/flutter_ioc_core # 纯 Dart IoC 容器核心 +# Flutter Forge + +> 把 Flutter 的底层机制、工程架构与跨平台能力,变成可以运行、交互、对照和验证的学习现场。 + +Flutter Forge 不是零散 Demo 的陈列柜,而是一座面向真实工程的 Flutter 学习工坊。项目将三棵树、事件循环、Isolate、状态管理、动效、3D、网络、文件选择、视频与原生能力组织为 **23 个可注册学习模块**;每个模块都带有学习语义、平台边界和独立测试,并运行在同一套响应式导航、模块注册与质量门禁之上。 + +[在线体验 Flutter Forge Web](https://lizy-coding.github.io/flutter_forge/) + +![Flutter Forge 演示](https://raw.githubusercontent.com/lizy-coding/flutter_forge/master/assets/demo.gif) + +完整演示视频:https://github.com/user-attachments/assets/6af279c0-7d82-42bc-81b1-624071b0e2ea + +## 为什么是 Flutter Forge + +- **从“看代码”到“看见机制”**:用可操作页面展示重建、队列调度、流订阅、并发、状态流转、绘制与平台调用。 +- **从单页示例到工程系统**:模块通过统一注册表进入目录与路由,共享能力有明确边界,不靠复制粘贴拼装功能。 +- **从“能运行”到“知道在哪里能运行”**:平台支持与学习质量分开建模;不支持的能力仍保留在目录中,并给出明确说明。 +- **从一次演示到可持续演进**:机器可解析的 Agent 契约、生成校验、静态分析、全量测试和 FlutterGuard 共同守住变更质量。 + +## 系统全景 + +```mermaid +flowchart TB + User[学习者] --> Host[Flutter Host
Android · macOS · Web · Windows] + + subgraph App[应用壳 apps/flutter_forge/lib/app] + Bootstrap[App Bootstrap
初始化平台快照] + Shell[MaterialApp.router
目录与学习入口] + Policy[NavigationPolicy
响应式导航决策] + Router[GoRouter
稳定路由与不可用守卫] + end + + subgraph Registry[模块注册层 module_registry] + Manifest[Module Manifest
23 个模块的元数据] + Catalog[Catalog Utils
分类、检索与平台判定] + Snapshot[AppPlatformSnapshot
进程级不可变平台模型] + end + + subgraph Modules[六类学习模块] + Basic[基础机制] + Async[异步并发] + State[架构与状态] + UI[UI 与动效] + Popup[弹窗与列表] + Platform[网络与平台] + end + + subgraph Shared[共享能力与工作区包] + Learning[LearningScaffold
教学页面模板] + MultiWindow[MultiWindowManager
桌面窗口生命周期] + Packages[file_picker_bridge
flutter_ioc_core
desktop_multi_window] + end + + Host --> Bootstrap --> Shell + Bootstrap --> Snapshot + Shell --> Policy + Shell --> Router + Policy --> Snapshot + Policy --> MultiWindow + Router --> Manifest --> Catalog + Catalog --> Snapshot + Catalog --> Modules + Modules --> Learning + Modules --> Packages ``` -## 维护文档 - -修改代码前先读根目录的: - -- `AGENTS.md`:agent 维护契约(修改前必读) -- `CONTEXT.md`:PC 封板、Android 兼容轨道和模块准入术语 -- `AI_PROJECT_CONTEXT.md`:项目整体上下文与目录约定 -- `REFACTOR_PLAN.md`:阶段性归拢重构计划 -- 目标模块或共享能力的 `AI_ANALYSIS.md` -- 质量验收标准与自动化测试计划:`docs/QUALITY_ACCEPTANCE.md` -- 专项演进计划:`docs/GCODE_VISUALIZER_EVOLUTION_PLAN.md` -- 历史验收报告:`docs/reports/` - -新增或迁移能力时需要同步更新对应层级的 `AI_ANALYSIS.md`。例如本轮同级包迁移后,相关文档为: +这套结构把职责切成四层:应用壳负责启动与导航,注册层负责模块事实与平台判定,模块层只承载学习内容和局部状态,共享层提供业务无关能力。普通模块不直接决定平台目录可见性,也不拥有多窗口策略。 -- `apps/flutter_forge/lib/shared/AI_ANALYSIS.md` -- `apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md` -- `packages/file_picker_bridge/AI_ANALYSIS.md` -- `packages/flutter_ioc_core/AI_ANALYSIS.md` -- `apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md` +### 导航与平台决策 -## 贡献指南 - -### 分支策略 - -- `dev` — 开发主分支 -- `feat/` — 功能分支 -- `fix/` — 修复分支 - -### 提交规范 +```mermaid +flowchart LR + Open[打开学习分类] --> Desktop{桌面平台?} + Desktop -- 否:Android / iOS / Web --> InApp[当前应用内导航] + Desktop -- 是 --> Width{窗口宽度 ≥ 600dp?} + Width -- 否 --> InApp + Width -- 是 --> Capability{多窗口能力可用?} + Capability -- 否 --> InApp + Capability -- 是 --> Window[创建或复用分类窗口] + Route[进入模块路由] --> Supported{当前目标平台可用?} + Supported -- 是 --> Lesson[加载教学模块] + Supported -- 否 --> Guard[显示统一不可用说明] ``` -(): -type: feat, fix, chore, docs, refactor, test -scope: 受影响的模块名或包名 -``` +Android、iOS 与 Web 始终使用应用内导航;桌面端只有在宽窗口且多窗口能力可用时才创建分类窗口。模块路径保持稳定,平台受限模块会进入统一说明页,而不是消失或落入 404。 -示例: -``` -feat(tree_state): add repaint boundary demo page -fix(gcode_core): resolve toolpath offset calculation -chore(packages): update agent doc schema -``` - -### PR 流程 - -1. 从 `dev` 创建功能分支 -2. 修改代码,遵循 AGENTS.md 规则 -3. 执行 `bash tool/quality_gate.sh`,确保通过 -4. 更新 AI_ANALYSIS.md(如适用) -5. 运行 `bash tool/generate_harness_ai_analysis.sh` -6. 提交 PR,填写 PR 模板 +## 学习地图 -### Definition of Done +| 主题 | 代表模块 | 你会观察到什么 | +|------|----------|----------------| +| 基础机制 | 三棵树与生命周期、事件循环、防抖与节流 | Widget/Element/RenderObject 关系、任务队列与调用时序 | +| 异步并发 | Stream 订阅、Isolate 对比、多任务管理器 | 背压、生命周期、主线程响应与跨 Isolate 通信 | +| 架构与状态 | 状态管理演进、Flutter IoC、本地持久化 | setState 到 Bloc 的取舍、依赖作用域与状态恢复 | +| UI 与动效 | 智能吸附线、下载动效、字体选择器、3D 查看器、G-code | 绘制、手势、Overlay、相机控制与 GPU 场景 | +| 弹窗与列表 | 弹窗合集、二维滚动表格、Overlay 跟随对照 | 浮层定位、嵌套路由与二维滚动布局 | +| 网络与平台 | Dio 拦截器、文件选择、在线视频、WebView、USB | 请求链路、原生边界、媒体生命周期与不可用降级 | -- [ ] 代码通过 `dart format .`(0 changed) -- [ ] 代码通过 `flutter analyze`(0 errors) -- [ ] 新增逻辑有测试覆盖 -- [ ] Agent 契约已更新(如适用) -- [ ] 质量门禁通过 -- [ ] 教学 UI 变更附截图/说明 +推荐从“三棵树与生命周期”开始,依次进入事件循环、Stream、Isolate、状态管理,再探索 UI/3D 与平台能力。目录中的难度、预计用时、概念标签和学习状态可以帮助你自行调整路线。 -### 禁止事项 +## 平台边界 -- 禁止 `path: ../...` 外部依赖 -- 禁止孤立 demo 页面(无解释无交互) -- 禁止首页出现纯工程目录名 -- 禁止修改生成物而不更新生成源 -- 禁止直接提交到 protected 分支 +Flutter Forge 的目标平台模型覆盖 Android、iOS、macOS、Web 和 Windows,但“进入目标集合”不等于所有能力都已完成真机验收。 -## 共享能力 +| 平台 | 当前仓库能力边界 | +|------|------------------| +| macOS | 桌面主线;支持宽窗口分类多窗口,部分 GPU/原生模块以 macOS 为已接入基线 | +| Windows | 桌面主线;核心真机能力已有记录,多窗口专项仍以验收报告为准 | +| Android | 单窗口兼容轨道;构建、模拟器遍历与基础原生通道已有证据,USB/键盘等真机行为仍需设备验收 | +| Web | 单窗口浏览器交付;Safari/Chrome 已覆盖主体模块,原生 WebView、USB、G-code 与 Isolate 模块保留不可用态 | +| iOS | 已进入平台判定模型;当前仓库没有 iOS Host 与构建证据,不应视为已交付 | -### 教学模板 +运行时以 `ModulePlatformSupport` 的排除集合描述平台限制,目录与路由统一通过 `module_registry` 判定。学习质量状态(`pending` / `ready` / `recommended`)不用于表达平台支持。 -`apps/flutter_forge/lib/shared/learning` 提供统一教学页面骨架,模块可以用学习目标、概念标签、代码片段、常见坑和练习卡片组织内容。 +详细证据见 [`docs/reports/`](docs/reports/);分层验收规则见 [`docs/QUALITY_ACCEPTANCE.md`](docs/QUALITY_ACCEPTANCE.md)。 -### 模块平台可用性 +## 快速开始 -平台限制只在模块注册表声明。非平台模块默认可用,平台模块通过 `supportedPlatforms` 显式列出支持平台;目录和路由层统一使用注册表判定结果。 +环境要求:Flutter `>=3.47.2`、Dart `^3.11.5`。 -### 平台文件选择 +```bash +# 在仓库根目录解析 Pub Workspace +flutter pub get -`packages/file_picker_bridge` 提供业务无关的文件选择接口: +# 启动主应用 +cd apps/flutter_forge +flutter run -d +``` -- Dart 接口:`FilePickerService` -- 默认实现:`MethodChannelFilePicker` -- macOS 通道:`file_picker_bridge/file_picker` -- 当前使用方:`modules/ui/gcode_visualizer` +Web Release 使用仓库提供的受检脚本,确保本地 CanvasKit、启动壳与同源媒体契约一致: -模块只传入允许的扩展名、标题和提示文案;文件读取、解析和错误展示仍由模块自己负责。 +```bash +bash tool/build_web_release.sh +``` -### G-code 核心 +提交变更前执行完整门禁: -[`gcode_core`](https://github.com/lizy-coding/gcode_core) 在独立仓库维护,应用通过 Git 依赖固定完整 commit,锁文件记录解析版本。升级时同步生成器中的外部依赖契约并执行完整质量门禁。该 Flutter 包提供: +```bash +bash tool/quality_gate.sh +``` -- 逐行读取抽象 -- G0/G1 解析 -- 错误收集 -- 轨迹段构建 +门禁会依次检查 Agent 文档漂移、Dart 格式、bare `flutter analyze`、全部测试、测试目录布局和 FlutterGuard HIGH 问题。 + +## 仓库结构 + +```text +flutter_forge/ +├── apps/flutter_forge/ # Flutter 应用与各平台 Host +│ ├── lib/app/ # 启动、应用壳、路由与导航策略 +│ ├── lib/module_registry/ # 模块元数据、平台快照与目录操作 +│ ├── lib/shared/ # 教学模板与业务无关的共享能力 +│ └── lib/modules/ # 六类、23 个学习模块 +├── packages/ +│ ├── file_picker_bridge/ # 跨平台文件选择边界 +│ ├── flutter_ioc_core/ # 纯 Dart IoC 核心 +│ └── desktop_multi_window/ # 桌面多窗口插件与本地修复 +├── docs/adr/ # 架构决策记录 +├── docs/reports/ # 分平台验收证据 +├── tool/ # 契约生成、测试与质量门禁 +├── AI_PROJECT_CONTEXT.md # 机器可解析项目契约 +└── REFACTOR_PLAN.md # 机器可解析演进队列 +``` -Canvas、时间线和播放控件由该包提供;教学页面、文件选择编排及播放状态保留在 `modules/ui/gcode_visualizer`。当前 macOS 最低支持版本为 12.0。 +`gcode_core` 作为独立 Git 依赖维护;Canvas、时间线和播放控件由它提供,教学编排与文件选择留在 Flutter Forge。工作区包禁止使用指向仓库外部的 `path: ../...` 依赖。 -## 示例索引(按主题) +## 如何新增或修改模块 -### UI 与动效 -- `modules/ui/adsorption_line`:智能吸附线画板,矩形/圆形/线条创建与拖拽,对齐辅助线、工具栏和快捷键。 -- `modules/ui/download_animation`:下载飞入动效三种实现(自定义 View / CustomPaint / Overlay),带参数调节面板。 -- `modules/ui/gcode_visualizer`:G-code 解析与轨迹动画,支持 G0/G1 指令解析、路径输入和系统文件选择、刀路轨迹绘制、播放进度控制与指令高亮,使用 `LearningScaffold` 教学模板。 +每个学习模块都不是孤立页面,而是一个受契约约束的可注册单元: -### 弹窗与列表 -- `modules/popup_table/popup_widgets`:常见弹窗合集,包含顺序链式 Overlay 演示与多种触发方式。 -- `modules/popup_table/popup_list_interaction`:弹窗与列表交互组合入口,集成弹窗演示与二维表格。 -- `modules/popup_table/scroll_table`:二维滚动表格,固定表头/行头,基于 `two_dimensional_scrollables`。 -- `modules/popup_table/overlay_follow_compare`:Overlay 跟随方案对照组,对比 `CompositedTransformFollower` 与 `markNeedsBuild` 两种浮层跟随机制。 +1. 使用 `module_entry.dart` 暴露 `*Entry` Widget。 +2. 在模块注册源中补全标题、副标题、分类、难度、概念、预计用时和状态。 +3. 至少使用一个 `lib/shared/learning` 教学模板组件。 +4. 维护模块 `AI_ANALYSIS.md`,并通过生成脚本校验机器契约。 +5. 为逻辑、交互和平台分支补充对应测试。 +6. 执行 `bash tool/quality_gate.sh`,涉及教学 UI 时补充人工验收或截图说明。 -### 基础机制 -- `modules/basic/tree_state`:Widget/Element/RenderObject 三棵树与生命周期、CustomPainter、RepaintBoundary 重绘范围示例。 -- `modules/basic/microtask`:事件循环演示,微任务队列与事件队列的执行顺序可视化。 -- `modules/basic/debounce_throttle`:防抖与节流执行时序对比。 +完整规则以 [`AGENTS.md`](AGENTS.md) 为准。架构与平台变更还应先阅读 [`CONTEXT.md`](CONTEXT.md) 和相关 [`docs/adr/`](docs/adr/)。 -### 异步与并发 -- `modules/async/isolate_basic`:主线程 vs Isolate 执行耗时计算的 UI 流畅度对比。 -- `modules/async/isolate_task_manager`:多任务 Isolate 管理器,支持进度上报、暂停/恢复/停止。 -- `modules/async/stream_subscription`:单订阅/广播流示例,包含暂停/恢复/取消与变换工具。 +## 协作与分支 -### 架构与状态 -- `modules/state/status_management`:状态管理演进链路,串联 setState、Provider、Riverpod、Bloc,附时间轴动画与测试用例。 -- `modules/state/flutter_ioc`:Provider + 自研 IoC 容器示例,支持注册/作用域/属性注入,含 `test/ioc_container_test.dart`。 +- `dev` 是持续开发分支,功能、修复、文档与发版准备先进入 `dev`。 +- `master` 是受保护稳定分支,只能通过从 `dev` 发起的 Pull Request 合入。 +- 不直接推送、强制推送或删除 `master`;合入后将合并拓扑同步回 `dev`。 +- 提交信息采用 `(): `,常用类型包括 `feat`、`fix`、`docs`、`refactor`、`test` 和 `chore`。 -### 网络与平台 -- `modules/platform/dio_interceptor`:Dio 拦截器链路(Auth/Error/Retry/Log)+ 内置本地 Mock Server,覆盖登录/列表/Token 刷新。 -- `modules/platform/usb_detector`:跨平台 USB 设备检测,周期扫描与手动刷新,使用 Stream 推送设备与状态消息。 +发布不由业务仓库 CI 直接创建 GitHub Release;发布计划与执行统一经 Agent Hub 的 `release_hosting` 流程完成。 diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index fa49bb1..4735fef 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -5,6 +5,7 @@ "completed_milestones": [ "directory_layers", "shared_package_extraction", + "module_manifest_route_composition", "module_analysis_coverage", "app_navigation_boundary", "host_bootstrap_boundary", @@ -12,7 +13,8 @@ "agent_takeover_ready", "pc_window_lifecycle_baseline", "pc_build_matrix", - "flutter_scene_3d_macos_baseline" + "flutter_scene_3d_macos_baseline", + "platform_snapshot_guarded_routes" ], "dependency_migration": { "layout": "pub_workspace", @@ -92,7 +94,12 @@ "acceptance": [ "no_windows_hardcode", "android_system_info", - "error_branch_test" + "error_branch_test", + "mobile_usb_route_disabled" + ], + "evidence": [ + "USB module remains catalog-visible but has no supported host routes", + "generic Android USB monitoring is deferred until a concrete OTG workflow exists" ] }, { @@ -285,6 +292,175 @@ "quality gate passed all six stages on 2026-09-13", "Windows, mobile and Web remain unavailable pending separate evidence" ] + }, + { + "id": "flutter_scene_3d_camera_motion", + "priority": 14, + "status": "pending", + "targets": [ + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d", + "apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart" + ], + "acceptance": [ + "bounded_two_axis_orbit", + "direct_manipulation_takeover", + "bounded_inertial_coasting", + "keyboard_and_reduced_motion", + "macos_motion_visual_acceptance" + ], + "evidence": [ + "controller and widget automation pass for takeover, bounds, inertia, mode comparison and reduced motion", + "expanded zoom bounds and the single-scene orientation thumbnail have controller and widget coverage", + "macOS Debug build passes and the real window renders the updated 3D first frame", + "macOS pointer, keyboard and motion-feel acceptance remains required for completion" + ] + }, + { + "id": "flutter_scene_3d_scene_picking", + "priority": 15, + "status": "pending", + "targets": [ + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + ], + "acceptance": [ + "nearest_visible_part_selection", + "drag_does_not_select", + "selection_highlight_and_details", + "focus_selected_part", + "clear_selection", + "macos_retina_pointer_acceptance" + ], + "evidence": [ + "selection state and widget focus flow have automated coverage", + "runtime uses screenPointToRay and Scene.raycast without physics", + "macOS Retina pointer and rendered highlight acceptance remains required" + ] + }, + { + "id": "flutter_scene_3d_windows_admission", + "priority": 16, + "status": "pending", + "targets": [ + "apps/flutter_forge/windows/runner/main.cpp", + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d", + "apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart" + ], + "acceptance": [ + "windows_catalog_and_route_admission", + "desktop_3d_viewer_automated_ui_flow", + "windows_debug_and_release_build", + "impeller_flutter_gpu_first_frame", + "windows_pointer_keyboard_and_dpi", + "selection_highlight_and_focus", + "windows_installer_launch" + ], + "evidence": [ + "Windows runner enables Flutter GPU through DartProject", + "catalog and route contract admits macOS and Windows", + "desktop integration flow covers navigation, camera input, selection, focus, clear and return", + "Windows host build, GPU, DPI, interaction, and installer evidence remains required" + ] + }, + { + "id": "flutter_scene_3d_android_view_only_admission", + "priority": 17, + "status": "pending", + "targets": [ + "apps/flutter_forge/android/app/src/main/AndroidManifest.xml", + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + ], + "acceptance": [ + "android_flutter_gpu_enabled", + "android_catalog_and_route_admission", + "android_view_only_scene", + "android_controls_selection_and_focus_hidden", + "android_debug_apk_build", + "android_gpu_first_frame_manual_evidence" + ], + "evidence": [ + "Android manifest enables Flutter GPU", + "module policy limits Android to automatic view-only rendering", + "catalog and widget contracts cover Android admission and hidden controls", + "Android emulator or device automation is intentionally deferred by scope", + "Android host build and GPU first-frame evidence remains required" + ] + }, + { + "id": "android_online_video_playback", + "priority": 18, + "status": "pending", + "targets": [ + "apps/flutter_forge/android/app/src/main/AndroidManifest.xml", + "apps/flutter_forge/lib/modules/platform/online_video_player", + "apps/flutter_forge/test/modules/platform/online_video_player" + ], + "acceptance": [ + "android_catalog_and_route_admission", + "android_release_internet_permission", + "compact_video_controls", + "android_debug_apk_build", + "android_real_video_playback" + ], + "evidence": [ + "video_player_android is registered in GeneratedPluginRegistrant", + "main Android manifest grants INTERNET for release playback", + "catalog and 320dp widget contracts cover Android entry", + "Android device playback evidence remains required" + ] + }, + { + "id": "android_file_picker_admission", + "priority": 19, + "status": "pending", + "targets": [ + "packages/file_picker_bridge", + "apps/flutter_forge/lib/modules/platform/file_picker", + "apps/flutter_forge/test/modules/platform/file_picker" + ], + "acceptance": [ + "android_catalog_and_route_admission", + "android_file_selector_backend", + "compact_file_picker_layout", + "android_debug_apk_build", + "android_real_device_pick_and_cancel" + ], + "evidence": [ + "file_picker_bridge selects file_selector on Android", + "file_selector_android is registered in GeneratedPluginRegistrant", + "catalog and compact widget contracts cover Android entry", + "Android real-device document provider behavior remains required" + ] + }, + { + "id": "platform_snapshot_guarded_routes", + "priority": 20, + "status": "completed", + "targets": [ + "apps/flutter_forge/lib/module_registry", + "apps/flutter_forge/lib/app", + "apps/flutter_forge/test/shared" + ], + "acceptance": [ + "process_immutable_platform_snapshot", + "five_product_target_platforms", + "excluded_platform_contract", + "stable_guarded_module_routes", + "unsupported_module_explanation", + "android_debug_apk_build", + "web_release_build" + ], + "evidence": [ + "bootstrap injects one AppPlatformSnapshot into Router and ProviderScope", + "module contracts use target_platforms and excluded_platforms", + "guarded route tests cover supported and unsupported module paths", + "bare analyze and full test suite pass", + "Android debug APK and Web release builds pass", + "iOS is modeled but has no host directory or build evidence" + ] } ], "quality_gate": [ @@ -301,8 +477,6 @@ "flutterguard_med_reduction", "recommended_module_visual_evidence", "flutter_scene_3d_model_loading", - "flutter_scene_3d_material_lighting", - "flutter_scene_3d_scene_picking", - "flutter_scene_3d_windows_admission" + "flutter_scene_3d_material_lighting" ] } diff --git a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml index 9b90bd6..5d6ec57 100644 --- a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + @@ -6,6 +7,9 @@ android:label="Flutter Forge" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + router.dispose()); + + Widget testApp() => ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ); + testWidgets('available modules open and return', (tester) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget(testApp()); await tester.pumpAndSettle(); expect(find.text('Flutter 学习实验室'), findsOneWidget); - final platform = defaultTargetPlatform; final modules = AppRouteTable.modules .where((module) => isModuleAvailable(module, platform)) .toList(); @@ -51,10 +64,10 @@ void main() { testWidgets('popup and list child routes open from the module page', ( tester, ) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget(testApp()); await tester.pumpAndSettle(); - AppRouter.router.go('/popup-list-interaction'); + router.go('/popup-list-interaction'); await tester.pumpAndSettle(); final popupTile = find.widgetWithText(ListTile, '弹窗组件'); @@ -65,10 +78,31 @@ void main() { await tester.pageBack(); await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300)); - AppRouter.router.go('/popup-list-interaction'); + router.go('/popup-list-interaction'); await tester.pumpAndSettle(); - AppRouter.router.go('/popup-list-interaction/list'); + router.go('/popup-list-interaction/list'); await tester.pumpAndSettle(); expect(find.text('二维滚动表格演示'), findsOneWidget); }); + + testWidgets('mobile category drawer opens a top-level category', ( + tester, + ) async { + if (platform.hostFamily != AppHostFamily.mobile) { + return; + } + + await tester.pumpWidget(testApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + expect(find.text('创作者主页'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('category-drawer:platform'))); + await tester.pumpAndSettle(); + + expect(find.byType(CategoryHomePage), findsOneWidget); + expect(find.text('网络与平台'), findsOneWidget); + }); } diff --git a/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart new file mode 100644 index 0000000..dad7c1a --- /dev/null +++ b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; +import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late AppPlatformSnapshot platform; + late GoRouter router; + + setUp(() { + platform = AppPlatformSnapshot.detect(); + router = AppRouter.create(platform)..go('/'); + }); + + tearDown(() => router.dispose()); + + testWidgets('3D viewer desktop interaction flow', (tester) async { + expect( + {TargetPlatform.macOS, TargetPlatform.windows}, + contains(defaultTargetPlatform), + reason: 'This acceptance flow requires a desktop Flutter GPU host.', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); + await _pumpFrames(tester, const Duration(milliseconds: 600)); + expect(find.text('Flutter 学习实验室'), findsOneWidget); + + final moduleTile = find.byKey(const ValueKey('module:/flutter-scene-3d')); + await tester.scrollUntilVisible( + moduleTile, + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(moduleTile); + await _pumpFrames(tester, const Duration(milliseconds: 200)); + await tester.tap(moduleTile); + await _pumpUntil( + tester, + find.byKey(const Key('scene-interaction-surface')), + ); + expect(find.byKey(const Key('scene-error')), findsNothing); + expect(find.text('设备部件检查'), findsOneWidget); + + await tester.tap(find.byTooltip('暂停环绕')); + await tester.pump(); + expect(find.textContaining('状态:已暂停'), findsOneWidget); + + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(find.text('教学型 3D 查看器'), findsWidgets); + + final surface = find.byKey(const Key('scene-interaction-surface')); + await tester.tap(find.byKey(const Key('enhanced-mode'))); + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter(surface), + scrollDelta: const Offset(0, -160), + ), + ); + await _pumpFrames(tester, const Duration(milliseconds: 250)); + expect(find.textContaining('缩放:1.19×'), findsOneWidget); + await tester.tap(find.byKey(const Key('scene-orientation-thumbnail'))); + await _pumpFrames(tester, const Duration(milliseconds: 450)); + + await tester.tapAt(tester.getCenter(surface)); + await _pumpUntil(tester, find.text('传感器')); + expect(find.byKey(const Key('focus-selected-part')), findsOneWidget); + + final focusButton = find.byKey(const Key('focus-selected-part')); + await tester.ensureVisible(focusButton); + await tester.tap(focusButton); + await _pumpFrames(tester, const Duration(milliseconds: 450)); + expect(find.textContaining('距离:3.0'), findsOneWidget); + + final clearButton = find.byKey(const Key('clear-selected-part')); + await tester.ensureVisible(clearButton); + await tester.tap(clearButton); + await tester.pump(); + expect(find.text('传感器'), findsNothing); + + await tester.pageBack(); + await _pumpFrames(tester, const Duration(milliseconds: 400)); + expect(find.text('Flutter 学习实验室'), findsOneWidget); + }); +} + +Future _pumpFrames(WidgetTester tester, Duration duration) async { + const step = Duration(milliseconds: 50); + var elapsed = Duration.zero; + while (elapsed < duration) { + await tester.pump(step); + elapsed += step; + } +} + +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 8), +}) async { + const step = Duration(milliseconds: 80); + var elapsed = Duration.zero; + while (finder.evaluate().isEmpty && elapsed < timeout) { + await tester.pump(step); + elapsed += step; + } + expect(finder, findsOneWidget); +} diff --git a/apps/flutter_forge/integration_test/windows_acceptance_test.dart b/apps/flutter_forge/integration_test/windows_acceptance_test.dart index aabb493..1b32d2a 100644 --- a/apps/flutter_forge/integration_test/windows_acceptance_test.dart +++ b/apps/flutter_forge/integration_test/windows_acceptance_test.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -11,10 +14,18 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Windows file picker returns from native dialog', (tester) async { - await tester.pumpWidget(const App()); + const platform = AppPlatformSnapshot(platform: AppTargetPlatform.windows); + final router = AppRouter.create(platform); + addTearDown(router.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); await tester.pump(const Duration(milliseconds: 500)); - AppRouter.router.go(_filePickerRoute); + router.go(_filePickerRoute); await tester.pump(const Duration(milliseconds: 500)); // README.md is a valid text-mode target. The test must not claim that the diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index 0b44f6f..d2ae6fa 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -1,6 +1,6 @@ { "schema": "flutter_forge.agent_docs.module_index.v1", - "registry": "lib/app/router/app_route_table.dart", + "registry": "lib/module_registry/module_manifest.dart", "count": 23, "modules": [ { @@ -14,6 +14,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/basic/tree_state/AI_ANALYSIS.md" }, { @@ -27,6 +37,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/basic/microtask/AI_ANALYSIS.md" }, { @@ -39,6 +59,16 @@ "shared_learning", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/basic/debounce_throttle/AI_ANALYSIS.md" }, { @@ -52,6 +82,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/async/stream_subscription/AI_ANALYSIS.md" }, { @@ -65,7 +105,18 @@ "module_registry", "go_router" ], - "supports_web": false, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] + }, "analysis": "lib/modules/async/isolate_basic/AI_ANALYSIS.md" }, { @@ -78,7 +129,18 @@ "shared_learning", "module_registry" ], - "supports_web": false, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] + }, "analysis": "lib/modules/async/isolate_task_manager/AI_ANALYSIS.md" }, { @@ -95,6 +157,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/state/status_management/AI_ANALYSIS.md" }, { @@ -109,6 +181,16 @@ "provider", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/state/flutter_ioc/AI_ANALYSIS.md" }, { @@ -122,6 +204,16 @@ "shared_preferences", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/state/local_persistence/AI_ANALYSIS.md" }, { @@ -136,9 +228,21 @@ "file_picker_bridge", "module_registry" ], - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "web", + "windows" + ] + }, "analysis": "lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md" }, { @@ -152,6 +256,16 @@ "provider", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/ui/adsorption_line/AI_ANALYSIS.md" }, { @@ -165,6 +279,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/ui/download_animation/AI_ANALYSIS.md" }, { @@ -179,6 +303,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/ui/font_picker/AI_ANALYSIS.md" }, { @@ -193,9 +327,19 @@ "vector_math", "module_registry" ], - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS", + "web" + ] + }, "analysis": "lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md" }, { @@ -208,6 +352,16 @@ "shared_learning", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md" }, { @@ -221,6 +375,16 @@ "module_registry", "go_router" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md" }, { @@ -234,6 +398,16 @@ "two_dimensional_scrollables", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/popup_table/scroll_table/AI_ANALYSIS.md" }, { @@ -246,6 +420,16 @@ "shared_learning", "module_registry" ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md" }, { @@ -260,7 +444,16 @@ "module_registry", "go_router" ], - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "analysis": "lib/modules/platform/dio_interceptor/AI_ANALYSIS.md" }, { @@ -274,9 +467,22 @@ "device_info_plus", "module_registry" ], - "supported_platforms": [ - "android" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ] + }, "analysis": "lib/modules/platform/usb_detector/AI_ANALYSIS.md" }, { @@ -290,11 +496,18 @@ "file_picker_bridge", "module_registry" ], - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS" + ] + }, "analysis": "lib/modules/platform/file_picker/AI_ANALYSIS.md" }, { @@ -311,11 +524,18 @@ "video_player_win", "module_registry" ], - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS" + ] + }, "analysis": "lib/modules/platform/online_video_player/AI_ANALYSIS.md" }, { @@ -330,11 +550,19 @@ "webview_flutter", "webview_windows" ], - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS", + "web" + ] + }, "analysis": "lib/modules/platform/webview/AI_ANALYSIS.md" } ] diff --git a/apps/flutter_forge/lib/app/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/AI_ANALYSIS.md index 28f567c..849c0e3 100644 --- a/apps/flutter_forge/lib/app/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/AI_ANALYSIS.md @@ -11,7 +11,12 @@ "entrypoints": [ "app.dart", "app_bootstrap.dart", + "app_platform_provider.dart", + "adaptive_app_shell.dart", + "creator_home_page.dart", + "creator_profile.dart", "module_home_page.dart", + "unsupported_module_page.dart", "category_navigation.dart", "navigation_policy.dart", "category_window_app.dart", @@ -20,15 +25,21 @@ ], "owns": [ "host_bootstrap", + "platform_snapshot_injection", "material_app_router", "router", - "module_home", - "responsive_navigation_policy", + "creator_home", + "module_catalog", + "title_search", + "unsupported_module_route", + "responsive_navigation_shell", "adaptive_category_navigation", "desktop_category_window_shell" ], "depends": [ "go_router", + "flutter_riverpod", + "url_launcher", "module_registry", "shared/multi_window", "modules" diff --git a/apps/flutter_forge/lib/app/adaptive_app_shell.dart b/apps/flutter_forge/lib/app/adaptive_app_shell.dart new file mode 100644 index 0000000..2f0d5ee --- /dev/null +++ b/apps/flutter_forge/lib/app/adaptive_app_shell.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../module_registry/module_category.dart'; +import '../module_registry/module_entry.dart'; +import 'navigation_policy.dart'; + +class AdaptiveAppShell extends StatefulWidget { + const AdaptiveAppShell({ + super.key, + required this.modules, + required this.child, + required this.location, + }); + + final List modules; + final Widget child; + final String location; + + @override + State createState() => _AdaptiveAppShellState(); +} + +class _AdaptiveAppShellState extends State { + bool _sidebarExpanded = true; + + int _selectedIndex() { + if (widget.location == '/') return 0; + for (var index = 0; index < ModuleCategory.values.length; index++) { + final category = ModuleCategory.values[index]; + if (widget.location == '/category/${category.name}' || + widget.modules.any( + (module) => + module.category == category && + widget.location.startsWith(module.path), + )) { + return index + 1; + } + } + return 0; + } + + void _goTo(int index) { + if (index == 0) { + context.go('/'); + return; + } + context.go('/category/${ModuleCategory.values[index - 1].name}'); + } + + Future _showSearch() async { + await showSearch( + context: context, + delegate: _ModuleSearchDelegate(widget.modules), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final layout = NavigationPolicy.layoutFor( + width: constraints.maxWidth, + sidebarExpanded: _sidebarExpanded, + ); + final compact = layout == AppNavigationLayout.compact; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.slash): _showSearch, + }, + child: Focus( + autofocus: true, + child: Scaffold( + key: const ValueKey('adaptive-app-shell'), + appBar: + compact && + (widget.location == '/' || + widget.location.startsWith('/category/')) + ? AppBar( + title: const Text('Flutter Forge'), + actions: [ + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), + ), + ], + ) + : null, + drawer: compact + ? NavigationDrawer( + selectedIndex: _selectedIndex(), + onDestinationSelected: (index) { + Navigator.of(context).pop(); + _goTo(index); + }, + children: _destinations + .map( + (destination) => NavigationDrawerDestination( + key: ValueKey( + 'category-drawer:${_destinations.indexOf(destination) == 0 ? 'home' : ModuleCategory.values[_destinations.indexOf(destination) - 1].name}', + ), + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ) + : null, + body: compact + ? widget.child + : Row( + children: [ + if (layout == AppNavigationLayout.sidebar) + _Sidebar( + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + onSearch: _showSearch, + onCollapse: () => + setState(() => _sidebarExpanded = false), + ) + else + NavigationRail( + key: const ValueKey('navigation-rail'), + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + leading: Column( + children: [ + IconButton( + tooltip: '展开侧栏', + onPressed: () => + setState(() => _sidebarExpanded = true), + icon: const Icon(Icons.menu_open), + ), + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), + ), + ], + ), + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: widget.child), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _Sidebar extends StatelessWidget { + const _Sidebar({ + required this.selectedIndex, + required this.onDestinationSelected, + required this.onSearch, + required this.onCollapse, + }); + + final int selectedIndex; + final ValueChanged onDestinationSelected; + final VoidCallback onSearch; + final VoidCallback onCollapse; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('expanded-sidebar'), + width: 264, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + title: const Text('Flutter Forge'), + subtitle: const Text('可交互的 Flutter 学习现场'), + trailing: IconButton( + tooltip: '收起侧栏', + onPressed: onCollapse, + icon: const Icon(Icons.menu_open), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: TextField( + key: const ValueKey('sidebar-search'), + readOnly: true, + onTap: onSearch, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: '搜索模块标题', + suffixIcon: Padding( + padding: const EdgeInsets.all(10), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: const Center( + child: Text( + '/', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + ), + ), + border: const OutlineInputBorder(), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _destinations.length, + itemBuilder: (context, index) { + final destination = _destinations[index]; + return ListTile( + key: ValueKey('sidebar-destination:$index'), + selected: index == selectedIndex, + leading: Icon(destination.icon), + title: Text(destination.label), + onTap: () => onDestinationSelected(index), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _Destination { + const _Destination(this.label, this.icon); + + final String label; + final IconData icon; +} + +final List<_Destination> _destinations = [ + const _Destination('创作者主页', Icons.home_outlined), + for (final category in ModuleCategory.values) + _Destination(category.label, category.icon), +]; + +class _ModuleSearchDelegate extends SearchDelegate { + _ModuleSearchDelegate(this.modules); + + final List modules; + + @override + String get searchFieldLabel => '搜索模块标题'; + + @override + List buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton( + tooltip: '清空搜索', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), + ]; + + @override + Widget buildLeading(BuildContext context) => IconButton( + tooltip: '返回', + onPressed: () => close(context, null), + icon: const Icon(Icons.arrow_back), + ); + + @override + Widget buildResults(BuildContext context) => _results(context); + + @override + Widget buildSuggestions(BuildContext context) => _results(context); + + Widget _results(BuildContext context) { + final normalized = query.trim().toLowerCase(); + final matches = normalized.isEmpty + ? const [] + : modules + .where( + (module) => module.title.toLowerCase().contains(normalized), + ) + .toList(growable: false); + if (normalized.isNotEmpty && matches.isEmpty) { + return Center( + child: TextButton( + onPressed: () => query = '', + child: const Text('没有匹配模块,清空搜索'), + ), + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (context, index) { + final module = matches[index]; + return ListTile( + title: Text(module.title), + subtitle: Text(module.category.label), + onTap: () { + close(context, null); + context.push(module.path); + }, + ); + }, + ); + } +} diff --git a/apps/flutter_forge/lib/app/app.dart b/apps/flutter_forge/lib/app/app.dart index 8c57c9d..a405786 100644 --- a/apps/flutter_forge/lib/app/app.dart +++ b/apps/flutter_forge/lib/app/app.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; - -import 'router/app_router.dart'; +import 'package:go_router/go_router.dart'; class App extends StatelessWidget { - const App({super.key}); + const App({super.key, required this.router}); + + final GoRouter router; @override Widget build(BuildContext context) { return MaterialApp.router( - routerConfig: AppRouter.router, + routerConfig: router, title: 'Flutter Forge', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), diff --git a/apps/flutter_forge/lib/app/app_bootstrap.dart b/apps/flutter_forge/lib/app/app_bootstrap.dart index 80d7c51..48f74e6 100644 --- a/apps/flutter_forge/lib/app/app_bootstrap.dart +++ b/apps/flutter_forge/lib/app/app_bootstrap.dart @@ -1,28 +1,39 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../shared/multi_window/multi_window_manager.dart'; import 'app.dart'; +import 'app_platform_provider.dart'; import 'category_window_app.dart'; +import 'router/app_router.dart'; /// Resolves the host-specific application shell before mounting Flutter. Future bootstrapFlutterForgeApp() async { WidgetsFlutterBinding.ensureInitialized(); + final platform = AppPlatformSnapshot.detect(); - Widget root = const App(); - if (!kIsWeb && MultiWindowManager.isSupported) { + Widget root = App(router: AppRouter.create(platform)); + if (!platform.isWeb && MultiWindowManager.isSupported) { final windowController = await WindowController.fromCurrentEngine(); final arguments = MultiWindowManager.parseArguments( windowController.arguments, ); if (arguments.type == WindowType.category && arguments.category != null) { - root = CategoryWindowApp(category: arguments.category!); + root = CategoryWindowApp( + category: arguments.category!, + platform: platform, + ); } else { await MultiWindowManager.instance.initialize(); } } - runApp(ProviderScope(child: root)); + runApp( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: root, + ), + ); } diff --git a/apps/flutter_forge/lib/app/app_platform_provider.dart b/apps/flutter_forge/lib/app/app_platform_provider.dart new file mode 100644 index 0000000..e43b4b9 --- /dev/null +++ b/apps/flutter_forge/lib/app/app_platform_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../module_registry/app_platform_snapshot.dart'; + +final appPlatformProvider = Provider( + (ref) => throw StateError('AppPlatformSnapshot was not initialized'), +); diff --git a/apps/flutter_forge/lib/app/category_navigation.dart b/apps/flutter_forge/lib/app/category_navigation.dart index ca45ac7..2b735db 100644 --- a/apps/flutter_forge/lib/app/category_navigation.dart +++ b/apps/flutter_forge/lib/app/category_navigation.dart @@ -1,12 +1,11 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; import '../shared/multi_window/multi_window_manager.dart'; -import 'category_window_app.dart'; -import 'navigation_policy.dart'; +import 'module_home_page.dart'; /// Selects the platform-appropriate way to open a module category. /// @@ -15,31 +14,24 @@ import 'navigation_policy.dart'; class CategoryNavigation { const CategoryNavigation._(); - static CategoryNavigationMode modeFor(BuildContext context) { - return NavigationPolicy.resolve( - platform: defaultTargetPlatform, - width: MediaQuery.sizeOf(context).width, - multiWindowSupported: MultiWindowManager.isSupported, - isWeb: kIsWeb, - ); - } - - static Future open( + static Future openInApp( BuildContext context, { required ModuleCategory category, required List modules, + required AppPlatformSnapshot platform, }) async { - if (modeFor(context) == CategoryNavigationMode.separateWindow) { - await MultiWindowManager.instance.createCategoryWindow(category); - return; - } - - if (!context.mounted) return; final filtered = filterModulesByCategory(modules, category); await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => CategoryHomePage(category: category, modules: filtered), + builder: (_) => CategoryHomePage( + category: category, + modules: filtered, + platform: platform, + ), ), ); } + + static Future openInNewWindow(ModuleCategory category) => + MultiWindowManager.instance.createCategoryWindow(category); } diff --git a/apps/flutter_forge/lib/app/category_window_app.dart b/apps/flutter_forge/lib/app/category_window_app.dart index 6a44dc2..a57315b 100644 --- a/apps/flutter_forge/lib/app/category_window_app.dart +++ b/apps/flutter_forge/lib/app/category_window_app.dart @@ -3,26 +3,44 @@ import 'package:go_router/go_router.dart'; import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; -import '../module_registry/module_entry.dart'; +import '../module_registry/app_platform_snapshot.dart'; import 'module_home_page.dart'; +import 'unsupported_module_page.dart'; import 'router/app_route_table.dart'; class CategoryWindowApp extends StatelessWidget { - const CategoryWindowApp({super.key, required this.category}); + const CategoryWindowApp({ + super.key, + required this.category, + required this.platform, + }); final ModuleCategory category; + final AppPlatformSnapshot platform; - static GoRouter createRouter(ModuleCategory category) { + static GoRouter createRouter( + ModuleCategory category, + AppPlatformSnapshot platform, + ) { final modules = filterModulesByCategory(AppRouteTable.modules, category); - final childRoutes = buildCategoryRoutes(modules); + final childRoutes = buildCategoryRoutes( + modules, + platform, + unsupportedBuilder: (_, module) => + UnsupportedModulePage(module: module, platform: platform), + ); return GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', - builder: (context, state) => - CategoryHomePage(category: category, modules: modules), + builder: (context, state) => CategoryHomePage( + category: category, + modules: modules, + platform: platform, + focusedWindow: true, + ), routes: childRoutes, ), ], @@ -32,7 +50,7 @@ class CategoryWindowApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( - routerConfig: CategoryWindowApp.createRouter(category), + routerConfig: CategoryWindowApp.createRouter(category, platform), title: category.label, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), @@ -41,41 +59,3 @@ class CategoryWindowApp extends StatelessWidget { ); } } - -class CategoryHomePage extends StatelessWidget { - const CategoryHomePage({ - super.key, - required this.category, - required this.modules, - }); - - final ModuleCategory category; - final List modules; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(category.label), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - Navigator.of(context).maybePop(); - } - }, - ), - ), - body: SafeArea( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: modules.length, - itemBuilder: (context, index) => - ModuleListTile(module: modules[index]), - ), - ), - ); - } -} diff --git a/apps/flutter_forge/lib/app/creator_home_page.dart b/apps/flutter_forge/lib/app/creator_home_page.dart new file mode 100644 index 0000000..42e6f9f --- /dev/null +++ b/apps/flutter_forge/lib/app/creator_home_page.dart @@ -0,0 +1,501 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../module_registry/module_category.dart'; +import 'creator_profile.dart'; + +class CreatorHomePage extends StatelessWidget { + const CreatorHomePage({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colors.primaryContainer.withValues(alpha: 0.42), + colors.surface, + colors.tertiaryContainer.withValues(alpha: 0.24), + ], + ), + ), + child: SingleChildScrollView( + key: const ValueKey('creator-home-scroll'), + padding: const EdgeInsets.fromLTRB(20, 32, 20, 40), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1120), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 860; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HeroSection(wide: wide), + const SizedBox(height: 40), + Text( + '从一个方向开始', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + '每个主题都由可运行示例、关键概念和实践路径组成。', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 18), + _CategoryGrid(wide: wide), + const SizedBox(height: 40), + const _CreatorFooter(), + ], + ); + }, + ), + ), + ), + ), + ); + } +} + +class _HeroSection extends StatelessWidget { + const _HeroSection({required this.wide}); + + final bool wide; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final introduction = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Text( + 'FLUTTER LEARNING WORKSPACE', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colors.onSecondaryContainer, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + '把 Flutter 学习\n变成一次次实践', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.12, + letterSpacing: -1.2, + ), + ), + const SizedBox(height: 16), + Text( + 'Flutter Forge 汇集跨平台、状态管理、异步与 UI 实验。不是孤立的 Demo,而是一座可以运行、拆解和验证的学习工坊。', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + height: 1.65, + ), + ), + const SizedBox(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + key: const ValueKey('start-learning'), + onPressed: () => + context.go('/category/${ModuleCategory.basic.name}'), + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('开始学习'), + ), + OutlinedButton.icon( + key: const ValueKey('creator-github-link'), + onPressed: () => launchUrl( + flutterForgeCreator.githubUri, + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.code_rounded), + label: const Text('GitHub'), + ), + ], + ), + ], + ); + + const highlights = _HighlightPanel(); + return Container( + padding: EdgeInsets.all(wide ? 36 : 24), + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: colors.outlineVariant), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.08), + blurRadius: 30, + offset: const Offset(0, 12), + ), + ], + ), + child: wide + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(flex: 6, child: introduction), + const SizedBox(width: 40), + const Expanded(flex: 4, child: highlights), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [introduction, const SizedBox(height: 32), highlights], + ), + ); + } +} + +class _HighlightPanel extends StatelessWidget { + const _HighlightPanel(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + const highlights = [ + (Icons.devices_rounded, '跨平台', '移动、桌面与 Web'), + (Icons.touch_app_rounded, '可交互', '从代码走向真实体验'), + (Icons.verified_outlined, '可验证', '测试与平台证据同行'), + ]; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(22), + ), + child: Column( + children: [ + for (var index = 0; index < highlights.length; index++) ...[ + _HighlightRow(data: highlights[index]), + if (index != highlights.length - 1) + Divider(height: 1, color: colors.outlineVariant), + ], + ], + ), + ); + } +} + +class _HighlightRow extends StatelessWidget { + const _HighlightRow({required this.data}); + + final (IconData, String, String) data; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(data.$1, color: colors.onPrimaryContainer), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.$2, style: Theme.of(context).textTheme.titleMedium), + Text( + data.$3, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _CategoryGrid extends StatelessWidget { + const _CategoryGrid({required this.wide}); + + final bool wide; + + @override + Widget build(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: ModuleCategory.values.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: wide ? 3 : 1, + mainAxisExtent: 118, + crossAxisSpacing: 14, + mainAxisSpacing: 14, + ), + itemBuilder: (context, index) { + final category = ModuleCategory.values[index]; + return _CategoryCard(category: category, index: index); + }, + ); + } +} + +class _CategoryCard extends StatelessWidget { + const _CategoryCard({required this.category, required this.index}); + + final ModuleCategory category; + final int index; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final accent = [ + colors.primary, + colors.tertiary, + colors.secondary, + ][index % 3]; + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('home-category:${category.name}'), + onTap: () => context.go('/category/${category.name}'), + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(15), + ), + child: Icon(category.icon, color: accent), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + category.label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Icon(Icons.arrow_outward_rounded, color: colors.onSurfaceVariant), + ], + ), + ), + ), + ); + } +} + +class _CreatorFooter extends StatelessWidget { + const _CreatorFooter(); + + @override + Widget build(BuildContext context) { + final profile = flutterForgeCreator; + final colors = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: colors.outlineVariant), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 760; + final identity = _CreatorIdentity(profile: profile); + final details = _CreatorDetails(profile: profile); + return wide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 220, child: identity), + const SizedBox(width: 32), + Expanded(child: details), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [identity, const SizedBox(height: 24), details], + ); + }, + ), + ); + } +} + +class _CreatorIdentity extends StatelessWidget { + const _CreatorIdentity({required this.profile}); + + final CreatorProfile profile; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 30, + backgroundColor: colors.primaryContainer, + foregroundColor: colors.onPrimaryContainer, + child: Text( + profile.name.substring(0, 1).toUpperCase(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + '@${profile.handle}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 16, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + const Text('深圳'), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _CreatorDetails extends StatelessWidget { + const _CreatorDetails({required this.profile}); + + final CreatorProfile profile; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.headline, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + profile.summary, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.55, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final area in profile.focusAreas) + Chip(visualDensity: VisualDensity.compact, label: Text(area)), + ], + ), + const SizedBox(height: 18), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _ProfileLink( + key: const ValueKey('profile-github-link'), + icon: Icons.code_rounded, + label: 'GitHub', + uri: profile.githubUri, + ), + _ProfileLink( + key: const ValueKey('profile-juejin-link'), + icon: Icons.article_outlined, + label: '掘金 · 人形打码机', + uri: profile.juejinUri, + ), + _ProfileLink( + key: const ValueKey('profile-yuque-link'), + icon: Icons.menu_book_outlined, + label: '语雀', + uri: profile.yuqueUri, + ), + ], + ), + ], + ); + } +} + +class _ProfileLink extends StatelessWidget { + const _ProfileLink({ + super.key, + required this.icon, + required this.label, + required this.uri, + }); + + final IconData icon; + final String label; + final Uri uri; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), + icon: Icon(icon), + label: Text(label), + ); + } +} diff --git a/apps/flutter_forge/lib/app/creator_profile.dart b/apps/flutter_forge/lib/app/creator_profile.dart new file mode 100644 index 0000000..2f65be0 --- /dev/null +++ b/apps/flutter_forge/lib/app/creator_profile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +@immutable +class CreatorProfile { + const CreatorProfile({ + required this.name, + required this.handle, + required this.headline, + required this.summary, + required this.focusAreas, + required this.githubUri, + required this.juejinUri, + required this.yuqueUri, + }); + + final String name; + final String handle; + final String headline; + final String summary; + final List focusAreas; + final Uri githubUri; + final Uri juejinUri; + final Uri yuqueUri; +} + +final CreatorProfile flutterForgeCreator = CreatorProfile( + name: 'Lizy', + handle: 'lizy-coding', + headline: 'AI Native Flutter Infrastructure Engineer', + summary: '专注构建 AI 驱动的 Flutter 工程体系与跨平台基础架构。相比孤立的业务功能,更关注可复用、可扩展、可持续演进的工程系统。', + focusAreas: const [ + 'Flutter 跨平台基础架构', + 'AI Engineering Workflow', + '高性能渲染与稳定性', + '复杂系统架构治理', + ], + githubUri: Uri.parse('https://github.com/lizy-coding'), + juejinUri: Uri.parse('https://juejin.cn/user/2085122730895063/posts'), + yuqueUri: Uri.parse('https://www.yuque.com/diligent_coding/flutter'), +); diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index ad3c9b1..6d26e87 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -1,172 +1,242 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; import '../module_registry/module_catalog_utils.dart'; -import 'category_navigation.dart'; -import 'navigation_policy.dart'; +import '../shared/multi_window/multi_window_manager.dart'; -class ModuleHomePage extends StatelessWidget { - const ModuleHomePage({super.key, required this.modules}); +class CategoryHomePage extends StatelessWidget { + const CategoryHomePage({ + super.key, + required this.category, + required this.modules, + required this.platform, + this.focusedWindow = false, + }); + final ModuleCategory category; final List modules; + final AppPlatformSnapshot platform; + final bool focusedWindow; @override Widget build(BuildContext context) { - const categories = ModuleCategory.values; - - return Scaffold( - appBar: AppBar(title: const Text('Flutter 学习实验室')), - body: SafeArea( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - final categoryModules = modules - .where((module) => module.category == category) - .toList(); - - if (categoryModules.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 8, 8), - child: Row( - children: [ - Expanded( - child: Text( - category.label, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - IconButton( - icon: Icon( - CategoryNavigation.modeFor(context) == - CategoryNavigationMode.separateWindow - ? Icons.open_in_new - : Icons.chevron_right, - size: 20, - ), - tooltip: '打开分类', - onPressed: () => CategoryNavigation.open( - context, - category: category, - modules: modules, - ), - ), - ], + final categoryModules = modules + .where((module) => module.category == category) + .toList(growable: false); + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: CustomScrollView( + key: ValueKey('category-page:${category.name}'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 12), + sliver: SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: _CategoryHeader( + category: category, + moduleCount: categoryModules.length, + showWindowAction: + !focusedWindow && + platform.hostFamily == AppHostFamily.desktop && + MultiWindowManager.isSupported, ), ), - ...categoryModules.map( - (module) => ModuleListTile(module: module), - ), - const Divider(height: 1), - ], - ); - }, - ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final columns = constraints.crossAxisExtent >= 820 ? 2 : 1; + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: 260, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => ModuleCard( + module: categoryModules[index], + platform: platform, + ), + childCount: categoryModules.length, + ), + ); + }, + ), + ), + ], ), ); } } -class ModuleListTile extends StatelessWidget { - const ModuleListTile({super.key, required this.module}); - - final ModuleEntry module; +class _CategoryHeader extends StatelessWidget { + const _CategoryHeader({ + required this.category, + required this.moduleCount, + required this.showWindowAction, + }); - Color _difficultyColor(Difficulty difficulty) { - return switch (difficulty) { - Difficulty.beginner => Colors.green, - Difficulty.intermediate => Colors.orange, - Difficulty.advanced => Colors.red, - }; - } + final ModuleCategory category; + final int moduleCount; + final bool showWindowAction; @override Widget build(BuildContext context) { - final difficultyColor = _difficultyColor(module.difficulty); - final isAvailable = isModuleAvailable(module, defaultTargetPlatform); - final textOpacity = isAvailable ? 1.0 : 0.55; - - return ListTile( - key: ValueKey('module:${module.path}'), - title: Row( - children: [ - Expanded( - child: Opacity(opacity: textOpacity, child: Text(module.title)), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: difficultyColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - module.difficulty.label, - style: TextStyle( - fontSize: 11, - color: difficultyColor, - fontWeight: FontWeight.w500, + final colors = Theme.of(context).colorScheme; + return DecoratedBox( + key: ValueKey('category-header:${category.name}'), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colors.primaryContainer.withValues(alpha: 0.72), + colors.surfaceContainerHigh, + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: colors.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(18), ), + child: Icon(category.icon, color: colors.onPrimary, size: 28), ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isAvailable ? module.subtitle : '当前平台暂不支持此模块', - style: TextStyle( - fontSize: 12, - color: Colors.black.withValues(alpha: textOpacity), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.label, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '$moduleCount 个学习模块', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 4, - children: module.concepts - .map( - (concept) => Container( + if (showWindowAction) ...[ + const SizedBox(width: 16), + Tooltip( + message: '在独立窗口中专注学习这个分类', + child: FilledButton.tonalIcon( + key: ValueKey('open-window:${category.name}'), + onPressed: () => MultiWindowManager.instance + .createCategoryWindow(category), + style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + horizontal: 18, + vertical: 16, ), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(4), + ), + icon: const Icon(Icons.open_in_new_rounded, size: 19), + label: const Text('在新窗口打开'), + ), + ), + ], + ], + ), + ), + ); + } +} + +class ModuleCard extends StatelessWidget { + const ModuleCard({super.key, required this.module, required this.platform}); + + final ModuleEntry module; + final AppPlatformSnapshot platform; + + @override + Widget build(BuildContext context) { + final available = isModuleAvailable(module, platform); + final concepts = module.concepts.take(3); + return Card( + child: InkWell( + key: ValueKey('module:${module.path}'), + borderRadius: BorderRadius.circular(12), + onTap: () => context.push(module.path), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + module.title, + style: Theme.of(context).textTheme.titleMedium, ), - child: Text(concept, style: const TextStyle(fontSize: 10)), ), - ) - .toList(), - ), - const SizedBox(height: 4), - Text( - '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', - style: TextStyle( - fontSize: 11, - color: Colors.grey.shade600.withValues(alpha: textOpacity), - ), + Chip( + visualDensity: VisualDensity.compact, + label: Text(module.difficulty.label), + ), + if (platform.isWeb) + IconButton( + key: ValueKey('open-tab:${module.path}'), + tooltip: '在新标签页打开', + visualDensity: VisualDensity.compact, + onPressed: () => launchUrl( + Uri.base.resolve(module.path), + webOnlyWindowName: '_blank', + ), + icon: const Icon(Icons.open_in_new_rounded, size: 19), + ), + ], + ), + const SizedBox(height: 6), + Text( + available ? module.subtitle : '当前平台暂不支持此模块', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (final concept in concepts) + Chip( + visualDensity: VisualDensity.compact, + label: Text(concept), + ), + ], + ), + const SizedBox(height: 6), + Text( + '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ], - ), - trailing: Icon( - isAvailable ? Icons.chevron_right : Icons.block, - color: isAvailable ? null : Colors.grey, + ), ), - onTap: isAvailable ? () => context.push(module.path) : null, ); } } diff --git a/apps/flutter_forge/lib/app/navigation_policy.dart b/apps/flutter_forge/lib/app/navigation_policy.dart index 24086b9..c7d505b 100644 --- a/apps/flutter_forge/lib/app/navigation_policy.dart +++ b/apps/flutter_forge/lib/app/navigation_policy.dart @@ -1,29 +1,19 @@ -import 'package:flutter/foundation.dart'; - -enum CategoryNavigationMode { inApp, separateWindow } +enum AppNavigationLayout { compact, rail, sidebar } class NavigationPolicy { const NavigationPolicy._(); static const double compactWidthBreakpoint = 600; + static const double sidebarWidthBreakpoint = 1024; - static CategoryNavigationMode resolve({ - required TargetPlatform platform, + static AppNavigationLayout layoutFor({ required double width, - required bool multiWindowSupported, - bool isWeb = false, + required bool sidebarExpanded, }) { - if (isWeb || - platform == TargetPlatform.android || - platform == TargetPlatform.iOS || - platform == TargetPlatform.fuchsia) { - return CategoryNavigationMode.inApp; - } - - if (width < compactWidthBreakpoint || !multiWindowSupported) { - return CategoryNavigationMode.inApp; + if (width < compactWidthBreakpoint) return AppNavigationLayout.compact; + if (width < sidebarWidthBreakpoint || !sidebarExpanded) { + return AppNavigationLayout.rail; } - - return CategoryNavigationMode.separateWindow; + return AppNavigationLayout.sidebar; } } diff --git a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md index c79b97b..4a09fbf 100644 --- a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md @@ -14,10 +14,16 @@ ], "owns": [ "go_router_root", + "adaptive_shell_route", + "creator_home_route", + "category_route", + "stable_guarded_module_routes", "module_route_aggregation", "module_catalog_composition" ], "depends": [ + "app/adaptive_app_shell", + "app/creator_home_page", "app/module_home_page", "module_registry", "modules" diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index 9067edc..e041174 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -1,400 +1,61 @@ // GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT -import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; +import '../adaptive_app_shell.dart'; +import '../creator_home_page.dart'; import '../module_home_page.dart'; -import '../../module_registry/module_category.dart'; +import '../unsupported_module_page.dart'; +import '../../module_registry/app_platform_snapshot.dart'; import '../../module_registry/module_catalog_utils.dart'; import '../../module_registry/module_entry.dart'; -import '../../modules/basic/debounce_throttle/module_entry.dart'; -import '../../modules/basic/microtask/module_entry.dart'; -import '../../modules/basic/microtask/module_routes.dart'; -import '../../modules/basic/tree_state/module_entry.dart'; -import '../../modules/basic/tree_state/module_routes.dart'; - -import '../../modules/async/isolate_basic/module_entry.dart'; -import '../../modules/async/isolate_basic/module_routes.dart'; -import '../../modules/async/isolate_task_manager/module_entry.dart'; -import '../../modules/async/stream_subscription/module_entry.dart'; -import '../../modules/async/stream_subscription/module_routes.dart'; - -import '../../modules/state/flutter_ioc/module_entry.dart'; -import '../../modules/state/status_management/module_routes.dart'; - -import '../../modules/state/status_management/module_entry.dart'; -import '../../modules/ui/adsorption_line/module_entry.dart'; -import '../../modules/ui/download_animation/module_entry.dart'; -import '../../modules/ui/download_animation/module_routes.dart'; -import '../../modules/ui/font_picker/module_entry.dart'; -import '../../modules/ui/font_picker/module_routes.dart'; -import '../../modules/ui/flutter_scene_3d/module_entry.dart'; -import '../../modules/ui/gcode_visualizer/module_entry.dart'; -import '../../modules/popup_table/popup_widgets/module_entry.dart'; -import '../../modules/popup_table/popup_list_interaction/module_entry.dart'; -import '../../modules/popup_table/popup_list_interaction/module_routes.dart'; -import '../../modules/popup_table/scroll_table/module_entry.dart'; -import '../../modules/popup_table/overlay_follow_compare/module_entry.dart'; - -import '../../modules/platform/dio_interceptor/module_entry.dart'; -import '../../modules/platform/dio_interceptor/module_routes.dart'; -import '../../modules/platform/file_picker/module_entry.dart'; -import '../../modules/platform/online_video_player/module_entry.dart'; -import '../../modules/platform/usb_detector/module_entry.dart'; -import '../../modules/state/local_persistence/module_entry.dart'; -import '../../modules/platform/webview/module_entry.dart'; +import '../../module_registry/module_category.dart'; +import '../../module_registry/module_manifest.dart'; -// ==================== 状态管理子路由(模块内部已定义映射) ==================== +List _moduleRoutesFor(AppPlatformSnapshot platform) => [ + for (final module in moduleManifest) + GoRoute( + path: module.path, + builder: (context, state) => isModuleAvailable(module, platform) + ? module.builder(context) + : UnsupportedModulePage(module: module, platform: platform), + routes: isModuleAvailable(module, platform) ? module.routes : const [], + ), +]; -List _buildStatusManageRoutes() => StatusManagementRoutes - .routes - .entries - .map( - (entry) => GoRoute( - path: entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, - builder: (context, state) => entry.value(context), +List _routesFor(AppPlatformSnapshot platform) => [ + ShellRoute( + builder: (context, state, child) => AdaptiveAppShell( + modules: moduleManifest, + location: state.uri.path, + child: child, + ), + routes: [ + GoRoute(path: '/', builder: (context, state) => const CreatorHomePage()), + GoRoute( + path: '/category/:category', + builder: (context, state) { + final name = state.pathParameters['category']; + final category = ModuleCategory.values.firstWhere( + (candidate) => candidate.name == name, + orElse: () => ModuleCategory.basic, + ); + return CategoryHomePage( + category: category, + modules: moduleManifest, + platform: platform, + ); + }, ), - ) - .toList(); - -// ==================== 模块注册 ==================== - -final List _modules = [ - // 基础机制 - ModuleEntry( - title: '三棵树与生命周期', - path: '/tree-state', - subtitle: '理解 Widget/Element/RenderObject 的关系与重建机制', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Widget 树', 'Element 树', 'RenderObject', '生命周期'], - estimatedMinutes: 30, - status: ModuleStatus.recommended, - builder: (context) => const TreeStateEntry(), - routes: TreeStateRoutes.routes, - ), - ModuleEntry( - title: '事件循环与微任务', - path: '/microtask', - subtitle: '掌握 Dart 事件循环中微任务队列与事件队列的执行顺序', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Microtask', 'Event Queue', 'async/await', 'Zone'], - estimatedMinutes: 25, - status: ModuleStatus.recommended, - builder: (context) => const MicrotaskEntry(), - routes: MicrotaskRoutes.routes, - ), - ModuleEntry( - title: '防抖与节流', - path: '/debounce-throttle', - subtitle: '对比防抖和节流的执行时序,理解适用场景', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Debouncer', 'Throttle', 'Timer'], - estimatedMinutes: 15, - status: ModuleStatus.ready, - builder: (context) => const DebounceThrottleEntry(), - ), - - // 异步并发 - ModuleEntry( - title: 'Stream 订阅机制', - path: '/stream-subscription', - subtitle: '学习单订阅流与广播流的区别及使用场景', - category: ModuleCategory.async, - difficulty: Difficulty.intermediate, - concepts: ['StreamController', '单订阅', '广播流', 'Stream 变换'], - estimatedMinutes: 30, - status: ModuleStatus.recommended, - builder: (context) => const StreamSubscriptionEntry(), - routes: StreamSubscriptionRoutes.routes, - ), - ModuleEntry( - title: 'Isolate 并发对比', - path: '/isolate-basic', - subtitle: '对比主线程与 Isolate 执行耗时计算对 UI 流畅度的影响', - category: ModuleCategory.async, - difficulty: Difficulty.intermediate, - concepts: ['Isolate', 'SendPort', 'ReceivePort', '性能优化'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - // Safari Web validation showed no usable progress lifecycle for Isolate.spawn. - supportsWeb: false, - builder: (context) => const IsolateTestEntry(), - routes: IsolateTestRoutes.routes, - ), - ModuleEntry( - title: '多任务 Isolate 管理器', - path: '/isolate-stream', - subtitle: '使用 Isolate 并行处理多任务,通过 Stream 实时上报进度', - category: ModuleCategory.async, - difficulty: Difficulty.advanced, - concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Safari Web validation showed tasks stalled at zero progress. - supportsWeb: false, - builder: (context) => const IsolateStreamEntry(), - ), - - // 状态管理 - ModuleEntry( - title: '状态管理演进', - path: '/status-management', - subtitle: '串联 setState、Provider、Riverpod、Bloc,对比不同方案', - category: ModuleCategory.state, - difficulty: Difficulty.intermediate, - concepts: ['Provider', 'Riverpod', 'Bloc', '状态提升', 'FutureProvider'], - estimatedMinutes: 45, - status: ModuleStatus.recommended, - builder: (context) => const StatusManageEntry(), - routes: _buildStatusManageRoutes(), - ), - ModuleEntry( - title: 'Flutter IoC 容器', - path: '/flutter-ioc', - subtitle: '自研 IoC 容器实现,支持单例/瞬态/作用域生命周期', - category: ModuleCategory.state, - difficulty: Difficulty.advanced, - concepts: ['IoC', '依赖注入', '生命周期', '作用域'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const FlutterIocEntry(), - ), - ModuleEntry( - title: '本地持久化', - path: '/local-persistence', - subtitle: '使用 shared_preferences 持久化设置项与计数器,理解异步读取与状态恢复', - category: ModuleCategory.state, - difficulty: Difficulty.intermediate, - concepts: ['shared_preferences', '键值存储', '异步读取', '状态恢复', '测试替身'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - builder: (context) => const LocalPersistenceEntry(), - ), - - // UI 与动效 - ModuleEntry( - title: 'G-code 解析与轨迹动画', - path: '/gcode-visualizer', - subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', - category: ModuleCategory.ui, - difficulty: Difficulty.advanced, - concepts: ['G-code', 'Parser', 'CustomPaint', 'PathMetric', '动画控制'], - estimatedMinutes: 45, - status: ModuleStatus.ready, - // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. - supportedPlatforms: {TargetPlatform.macOS}, - builder: (context) => const GcodeVisualizerEntry(), - ), - ModuleEntry( - title: '智能吸附线画板', - path: '/adsorption-line', - subtitle: '类似设计工具的对齐吸附功能,学习自定义绘制与手势', - category: ModuleCategory.ui, - difficulty: Difficulty.advanced, - concepts: ['CustomPaint', '手势检测', '吸附算法', '状态管理'], - estimatedMinutes: 40, - status: ModuleStatus.ready, - builder: (context) => const AdsorptionLineEntry(), - ), - ModuleEntry( - title: '下载飞入动效', - path: '/download-animation', - subtitle: '三种实现方式对比:Custom View / CustomPaint / Overlay', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: ['Tween 动画', 'CustomPaint', 'OverlayEntry', '动画配置'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const DownloadAnimationEntry(), - routes: DownloadAnimationRoutes.routes, - ), - ModuleEntry( - title: '字体选择器', - path: '/font-picker', - subtitle: '命名列表中直观对比不同字体族与字重样式,并通过文件选择器加载本地字体', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: [ - 'fontFamily', - 'TextStyle', - '字重', - '字距', - '字体 fallback', - '平台字体', - 'FontLoader', - '中台复用', - ], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const FontPickerEntry(), - routes: FontPickerRoutes.routes, - ), - ModuleEntry( - title: 'Flutter Scene 3D 入门', - path: '/flutter-scene-3d', - subtitle: '用一个可交互长方体理解场景、网格与相机控制', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'PerspectiveCamera'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - // Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable. - supportedPlatforms: {TargetPlatform.macOS}, - builder: (context) => const FlutterScene3dEntry(), - ), - - // 弹窗与列表 - ModuleEntry( - title: '弹窗合集', - path: '/popup-widgets', - subtitle: '全面展示 Flutter 中的对话框、底部抽屉、菜单等弹窗类型', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['AlertDialog', 'BottomSheet', 'Overlay', 'ContextMenu'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - builder: (context) => const PopWidgetEntry(), - ), - ModuleEntry( - title: '弹窗与列表交互', - path: '/popup-list-interaction', - subtitle: 'Flutter 弹窗组件与二维滚动表格的综合演示', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['Dialog', 'BottomSheet', 'Overlay', 'TableView', '二维滚动'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - builder: (context) => const PopupListInteractionEntry(), - routes: PopupListInteractionRoutes.routes, - ), - ModuleEntry( - title: '二维滚动表格', - path: '/scroll-table', - subtitle: '使用 two_dimensional_scrollables 实现固定表头的表格', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['TableView', '固定表头', '二维滚动'], - estimatedMinutes: 15, - status: ModuleStatus.ready, - builder: (context) => const ScrollTableEntry(), - ), - ModuleEntry( - title: 'Overlay 跟随方案对照组', - path: '/overlay-compare', - subtitle: '对比 CompositedTransformFollower 与 markNeedsBuild 两种浮层跟随方案', - category: ModuleCategory.popupTable, - difficulty: Difficulty.intermediate, - concepts: [ - 'Overlay', - 'LayerLink', - 'CompositedTransformFollower', - 'markNeedsBuild', - 'ScrollController', - ], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const OverlayFollowCompareEntry(), - ), - - // 网络与平台 - ModuleEntry( - title: 'Dio 拦截器链路', - path: '/dio-interceptor', - subtitle: 'Auth/Error/Retry/Log 拦截器 + 本地 Mock Server 实战', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['Dio', '拦截器', 'Token 刷新', 'Mock Server', '重试机制'], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Web uses an in-memory Dio adapter; native hosts keep the localhost mock server. - supportsWeb: true, - builder: (context) => const InterceptorTestEntry(), - routes: InterceptorTestRoutes.routes, - ), - ModuleEntry( - title: 'USB 设备检测', - path: '/usb-detector', - subtitle: 'Android USB 设备检测与状态监控', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - // Android-only: Windows 原生插件已移除,目录显示不可用、路由不注册 - supportedPlatforms: {TargetPlatform.android}, - builder: (context) => const UsbDetectorEntry(), - ), - ModuleEntry( - title: '文件选择器', - path: '/file-picker', - subtitle: '复用 file_picker_bridge 中台能力,演示扩展过滤、取消分支与平台差异', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - // Web accepts one user-selected file and exposes only its filename. - supportsWeb: true, - supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, - builder: (context) => const FilePickerEntry(), - ), - ModuleEntry( - title: '在线视频播放', - path: '/online-video-player', - subtitle: '使用 video_player 播放在线 HTTP 视频流并操控播放参数', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: [ - 'video_player', - '平台播放器', - 'HTTP 流', - '播放控制', - '倍速', - 'Controller 生命周期', + ..._moduleRoutesFor(platform), ], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Web uses a same-origin media asset and starts playback from a user gesture. - supportsWeb: true, - supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, - builder: (context) => const OnlineVideoPlayerEntry(), - ), - ModuleEntry( - title: '网页容器与跨平台导航', - path: '/webview', - subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - // Android/macOS use webview_flutter; Windows uses WebView2. - supportedPlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - builder: (context) => const WebViewEntry(), ), ]; -final List _routes = [ - GoRoute( - path: '/', - builder: (context, state) => ModuleHomePage(modules: _modules), - ), - for (final module in availableModules(_modules)) - GoRoute( - path: module.path, - builder: (context, state) => module.builder(context), - routes: module.routes, - ), -]; - class AppRouteTable { - static List get routes => _routes; - static List get modules => _modules; + static List routesFor(AppPlatformSnapshot platform) => + _routesFor(platform); + static List moduleRoutesFor(AppPlatformSnapshot platform) => + _moduleRoutesFor(platform); + static List get modules => moduleManifest; } diff --git a/apps/flutter_forge/lib/app/router/app_router.dart b/apps/flutter_forge/lib/app/router/app_router.dart index 22ef56d..951e6b0 100644 --- a/apps/flutter_forge/lib/app/router/app_router.dart +++ b/apps/flutter_forge/lib/app/router/app_router.dart @@ -1,9 +1,11 @@ import 'package:go_router/go_router.dart'; +import '../../module_registry/app_platform_snapshot.dart'; import 'app_route_table.dart'; class AppRouter { AppRouter._(); - static final GoRouter router = GoRouter(routes: AppRouteTable.routes); + static GoRouter create(AppPlatformSnapshot platform) => + GoRouter(routes: AppRouteTable.routesFor(platform)); } diff --git a/apps/flutter_forge/lib/app/unsupported_module_page.dart b/apps/flutter_forge/lib/app/unsupported_module_page.dart new file mode 100644 index 0000000..8ca9374 --- /dev/null +++ b/apps/flutter_forge/lib/app/unsupported_module_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../module_registry/app_platform_snapshot.dart'; +import '../module_registry/module_entry.dart'; + +class UnsupportedModulePage extends StatelessWidget { + const UnsupportedModulePage({ + super.key, + required this.module, + required this.platform, + }); + + final ModuleEntry module; + final AppPlatformSnapshot platform; + + @override + Widget build(BuildContext context) { + final supportedPlatforms = AppTargetPlatform.values + .where((candidate) => candidate != AppTargetPlatform.unsupported) + .where( + (candidate) => + !module.platformSupport.excludedPlatforms.contains(candidate), + ) + .map((candidate) => candidate.label) + .join('、'); + + return Scaffold( + appBar: AppBar(title: Text(module.title)), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + '当前平台暂不支持此模块', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('当前平台:${platform.platform.label}'), + const SizedBox(height: 8), + Text( + supportedPlatforms.isEmpty + ? '当前没有开放的目标平台' + : '支持平台:$supportedPlatforms', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => context.go('/'), + child: const Text('返回模块目录'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md index 0d3c8fb..b69cfdd 100644 --- a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md @@ -9,16 +9,20 @@ "status": "active" }, "entrypoints": [ + "app_platform_snapshot.dart", "module_entry.dart", "module_category.dart", + "module_platform_support.dart", "module_catalog_utils.dart" ], "owns": [ + "platform_snapshot", + "product_target_platforms", "module_entry_model", "module_category_enum", "difficulty_enum", "module_status_enum", - "native_web_availability", + "excluded_platform_availability", "module_catalog_filtering", "category_route_rebasing" ], diff --git a/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart b/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart new file mode 100644 index 0000000..ede2308 --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; + +enum AppTargetPlatform { android, iOS, macOS, web, windows, unsupported } + +enum AppHostFamily { mobile, desktop, web, unsupported } + +extension AppTargetPlatformLabel on AppTargetPlatform { + String get label => switch (this) { + AppTargetPlatform.android => 'Android', + AppTargetPlatform.iOS => 'iOS', + AppTargetPlatform.macOS => 'macOS', + AppTargetPlatform.web => 'Web', + AppTargetPlatform.windows => 'Windows', + AppTargetPlatform.unsupported => '非产品目标平台', + }; +} + +/// Immutable host identity captured once for the current application process. +class AppPlatformSnapshot { + const AppPlatformSnapshot({required this.platform}); + + factory AppPlatformSnapshot.detect() { + return AppPlatformSnapshot.fromFlutter( + isWeb: kIsWeb, + targetPlatform: defaultTargetPlatform, + ); + } + + factory AppPlatformSnapshot.fromFlutter({ + required bool isWeb, + required TargetPlatform targetPlatform, + }) { + if (isWeb) { + return const AppPlatformSnapshot(platform: AppTargetPlatform.web); + } + + return AppPlatformSnapshot( + platform: switch (targetPlatform) { + TargetPlatform.android => AppTargetPlatform.android, + TargetPlatform.iOS => AppTargetPlatform.iOS, + TargetPlatform.macOS => AppTargetPlatform.macOS, + TargetPlatform.windows => AppTargetPlatform.windows, + TargetPlatform.linux || + TargetPlatform.fuchsia => AppTargetPlatform.unsupported, + }, + ); + } + + final AppTargetPlatform platform; + + bool get isWeb => platform == AppTargetPlatform.web; + + bool get isProductTarget => platform != AppTargetPlatform.unsupported; + + AppHostFamily get hostFamily => switch (platform) { + AppTargetPlatform.android || AppTargetPlatform.iOS => AppHostFamily.mobile, + AppTargetPlatform.macOS || + AppTargetPlatform.windows => AppHostFamily.desktop, + AppTargetPlatform.web => AppHostFamily.web, + AppTargetPlatform.unsupported => AppHostFamily.unsupported, + }; +} diff --git a/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart b/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart index 225157b..06bdf79 100644 --- a/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart +++ b/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart @@ -1,6 +1,7 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'app_platform_snapshot.dart'; import 'module_category.dart'; import 'module_entry.dart'; @@ -11,34 +12,33 @@ List filterModulesByCategory( return allModules.where((module) => module.category == category).toList(); } -bool isModuleAvailable( - ModuleEntry module, [ - TargetPlatform? platform, - bool? web, -]) { - return module.isSupportedOn( - platform ?? defaultTargetPlatform, - isWeb: web ?? kIsWeb, - ); -} +bool isModuleAvailable(ModuleEntry module, AppPlatformSnapshot platform) => + module.isSupportedOn(platform); List availableModules( - List modules, [ - TargetPlatform? platform, - bool? web, -]) { + List modules, + AppPlatformSnapshot platform, +) { return modules - .where((module) => isModuleAvailable(module, platform, web)) + .where((module) => isModuleAvailable(module, platform)) .toList(); } -List buildCategoryRoutes(List modules) { +List buildCategoryRoutes( + List modules, + AppPlatformSnapshot platform, { + required Widget Function(BuildContext, ModuleEntry) unsupportedBuilder, +}) { return [ - for (final module in availableModules(modules)) + for (final module in modules) GoRoute( path: _stripLeadingSlash(module.path), - builder: (context, state) => module.builder(context), - routes: _rebasedRoutes(module.routes), + builder: (context, state) => isModuleAvailable(module, platform) + ? module.builder(context) + : unsupportedBuilder(context, module), + routes: isModuleAvailable(module, platform) + ? _rebasedRoutes(module.routes) + : const [], ), ]; } diff --git a/apps/flutter_forge/lib/module_registry/module_category.dart b/apps/flutter_forge/lib/module_registry/module_category.dart index b0de7f8..ba19c50 100644 --- a/apps/flutter_forge/lib/module_registry/module_category.dart +++ b/apps/flutter_forge/lib/module_registry/module_category.dart @@ -1,14 +1,17 @@ +import 'package:flutter/material.dart'; + /// 模块分类 enum ModuleCategory { - basic('基础机制'), - async('异步并发'), - state('状态管理'), - ui('UI 与动效'), - popupTable('弹窗与列表'), - platform('网络与平台'); + basic('基础机制', Icons.account_tree_outlined), + async('异步并发', Icons.bolt_outlined), + state('架构与状态', Icons.hub_outlined), + ui('UI 与动效', Icons.palette_outlined), + popupTable('弹窗与列表', Icons.table_chart_outlined), + platform('网络与平台', Icons.devices_outlined); - const ModuleCategory(this.label); + const ModuleCategory(this.label, this.icon); final String label; + final IconData icon; } /// 难度等级 diff --git a/apps/flutter_forge/lib/module_registry/module_entry.dart b/apps/flutter_forge/lib/module_registry/module_entry.dart index a1e5536..5f56943 100644 --- a/apps/flutter_forge/lib/module_registry/module_entry.dart +++ b/apps/flutter_forge/lib/module_registry/module_entry.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'app_platform_snapshot.dart'; import 'module_category.dart'; +import 'module_platform_support.dart'; class ModuleEntry { ModuleEntry({ @@ -15,8 +17,7 @@ class ModuleEntry { required this.status, required this.builder, this.routes = const [], - this.supportedPlatforms, - this.supportsWeb, + required this.platformSupport, }); final String title; @@ -30,21 +31,8 @@ class ModuleEntry { final WidgetBuilder builder; final List routes; - /// Null means the module is platform-neutral and supported by default. - /// A non-null set restricts availability to the listed host platforms. - final Set? supportedPlatforms; + final ModulePlatformSupport platformSupport; - /// Overrides Web availability independently from native host support. - /// - /// Platform-neutral modules support Web by default. Set this to false when - /// an otherwise platform-neutral module depends on a native-only runtime - /// capability. A module with an explicit [supportedPlatforms] set is - /// unavailable on Web unless this is explicitly true. - final bool? supportsWeb; - - bool isSupportedOn(TargetPlatform platform, {required bool isWeb}) { - if (isWeb) return supportsWeb ?? supportedPlatforms == null; - final platforms = supportedPlatforms; - return platforms == null || platforms.contains(platform); - } + bool isSupportedOn(AppPlatformSnapshot snapshot) => + platformSupport.supports(snapshot); } diff --git a/apps/flutter_forge/lib/module_registry/module_manifest.dart b/apps/flutter_forge/lib/module_registry/module_manifest.dart new file mode 100644 index 0000000..7746094 --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/module_manifest.dart @@ -0,0 +1,416 @@ +// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT + +import 'package:go_router/go_router.dart'; + +import 'app_platform_snapshot.dart'; +import 'module_category.dart'; +import 'module_entry.dart'; +import 'module_platform_support.dart'; +import '../modules/basic/debounce_throttle/module_entry.dart'; +import '../modules/basic/microtask/module_entry.dart'; +import '../modules/basic/microtask/module_routes.dart'; +import '../modules/basic/tree_state/module_entry.dart'; +import '../modules/basic/tree_state/module_routes.dart'; + +import '../modules/async/isolate_basic/module_entry.dart'; +import '../modules/async/isolate_basic/module_routes.dart'; +import '../modules/async/isolate_task_manager/module_entry.dart'; +import '../modules/async/stream_subscription/module_entry.dart'; +import '../modules/async/stream_subscription/module_routes.dart'; + +import '../modules/state/flutter_ioc/module_entry.dart'; +import '../modules/state/status_management/module_routes.dart'; + +import '../modules/state/status_management/module_entry.dart'; +import '../modules/ui/adsorption_line/module_entry.dart'; +import '../modules/ui/download_animation/module_entry.dart'; +import '../modules/ui/download_animation/module_routes.dart'; +import '../modules/ui/font_picker/module_entry.dart'; +import '../modules/ui/font_picker/module_routes.dart'; +import '../modules/ui/flutter_scene_3d/module_entry.dart'; +import '../modules/ui/gcode_visualizer/module_entry.dart'; +import '../modules/popup_table/popup_widgets/module_entry.dart'; +import '../modules/popup_table/popup_list_interaction/module_entry.dart'; +import '../modules/popup_table/popup_list_interaction/module_routes.dart'; +import '../modules/popup_table/scroll_table/module_entry.dart'; +import '../modules/popup_table/overlay_follow_compare/module_entry.dart'; + +import '../modules/platform/dio_interceptor/module_entry.dart'; +import '../modules/platform/dio_interceptor/module_routes.dart'; +import '../modules/platform/file_picker/module_entry.dart'; +import '../modules/platform/online_video_player/module_entry.dart'; +import '../modules/platform/usb_detector/module_entry.dart'; +import '../modules/state/local_persistence/module_entry.dart'; +import '../modules/platform/webview/module_entry.dart'; + +// ==================== 状态管理子路由(模块内部已定义映射) ==================== + +List _buildStatusManageRoutes() => StatusManagementRoutes + .routes + .entries + .map( + (entry) => GoRoute( + path: entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, + builder: (context, state) => entry.value(context), + ), + ) + .toList(); + +// ==================== 模块注册 ==================== + +final List moduleManifest = [ + // 基础机制 + ModuleEntry( + title: '三棵树与生命周期', + path: '/tree-state', + subtitle: '理解 Widget/Element/RenderObject 的关系与重建机制', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Widget 树', 'Element 树', 'RenderObject', '生命周期'], + estimatedMinutes: 30, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const TreeStateEntry(), + routes: TreeStateRoutes.routes, + ), + ModuleEntry( + title: '事件循环与微任务', + path: '/microtask', + subtitle: '掌握 Dart 事件循环中微任务队列与事件队列的执行顺序', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Microtask', 'Event Queue', 'async/await', 'Zone'], + estimatedMinutes: 25, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const MicrotaskEntry(), + routes: MicrotaskRoutes.routes, + ), + ModuleEntry( + title: '防抖与节流', + path: '/debounce-throttle', + subtitle: '对比防抖和节流的执行时序,理解适用场景', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Debouncer', 'Throttle', 'Timer'], + estimatedMinutes: 15, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const DebounceThrottleEntry(), + ), + + // 异步并发 + ModuleEntry( + title: 'Stream 订阅机制', + path: '/stream-subscription', + subtitle: '学习单订阅流与广播流的区别及使用场景', + category: ModuleCategory.async, + difficulty: Difficulty.intermediate, + concepts: ['StreamController', '单订阅', '广播流', 'Stream 变换'], + estimatedMinutes: 30, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const StreamSubscriptionEntry(), + routes: StreamSubscriptionRoutes.routes, + ), + ModuleEntry( + title: 'Isolate 并发对比', + path: '/isolate-basic', + subtitle: '对比主线程与 Isolate 执行耗时计算对 UI 流畅度的影响', + category: ModuleCategory.async, + difficulty: Difficulty.intermediate, + concepts: ['Isolate', 'SendPort', 'ReceivePort', '性能优化'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + // Safari Web validation showed no usable progress lifecycle for Isolate.spawn. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.web}, + ), + builder: (context) => const IsolateTestEntry(), + routes: IsolateTestRoutes.routes, + ), + ModuleEntry( + title: '多任务 Isolate 管理器', + path: '/isolate-stream', + subtitle: '使用 Isolate 并行处理多任务,通过 Stream 实时上报进度', + category: ModuleCategory.async, + difficulty: Difficulty.advanced, + concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Safari Web validation showed tasks stalled at zero progress. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.web}, + ), + builder: (context) => const IsolateStreamEntry(), + ), + + // 状态管理 + ModuleEntry( + title: '状态管理演进', + path: '/status-management', + subtitle: '串联 setState、Provider、Riverpod、Bloc,对比不同方案', + category: ModuleCategory.state, + difficulty: Difficulty.intermediate, + concepts: ['Provider', 'Riverpod', 'Bloc', '状态提升', 'FutureProvider'], + estimatedMinutes: 45, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const StatusManageEntry(), + routes: _buildStatusManageRoutes(), + ), + ModuleEntry( + title: 'Flutter IoC 容器', + path: '/flutter-ioc', + subtitle: '自研 IoC 容器实现,支持单例/瞬态/作用域生命周期', + category: ModuleCategory.state, + difficulty: Difficulty.advanced, + concepts: ['IoC', '依赖注入', '生命周期', '作用域'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const FlutterIocEntry(), + ), + ModuleEntry( + title: '本地持久化', + path: '/local-persistence', + subtitle: '使用 shared_preferences 持久化设置项与计数器,理解异步读取与状态恢复', + category: ModuleCategory.state, + difficulty: Difficulty.intermediate, + concepts: ['shared_preferences', '键值存储', '异步读取', '状态恢复', '测试替身'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const LocalPersistenceEntry(), + ), + + // UI 与动效 + ModuleEntry( + title: 'G-code 解析与轨迹动画', + path: '/gcode-visualizer', + subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', + category: ModuleCategory.ui, + difficulty: Difficulty.advanced, + concepts: ['G-code', 'Parser', 'CustomPaint', 'PathMetric', '动画控制'], + estimatedMinutes: 45, + status: ModuleStatus.ready, + // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, + ), + builder: (context) => const GcodeVisualizerEntry(), + ), + ModuleEntry( + title: '智能吸附线画板', + path: '/adsorption-line', + subtitle: '类似设计工具的对齐吸附功能,学习自定义绘制与手势', + category: ModuleCategory.ui, + difficulty: Difficulty.advanced, + concepts: ['CustomPaint', '手势检测', '吸附算法', '状态管理'], + estimatedMinutes: 40, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const AdsorptionLineEntry(), + ), + ModuleEntry( + title: '下载飞入动效', + path: '/download-animation', + subtitle: '三种实现方式对比:Custom View / CustomPaint / Overlay', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: ['Tween 动画', 'CustomPaint', 'OverlayEntry', '动画配置'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const DownloadAnimationEntry(), + routes: DownloadAnimationRoutes.routes, + ), + ModuleEntry( + title: '字体选择器', + path: '/font-picker', + subtitle: '命名列表中直观对比不同字体族与字重样式,并通过文件选择器加载本地字体', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: [ + 'fontFamily', + 'TextStyle', + '字重', + '字距', + '字体 fallback', + '平台字体', + 'FontLoader', + '中台复用', + ], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const FontPickerEntry(), + routes: FontPickerRoutes.routes, + ), + ModuleEntry( + title: '教学型 3D 查看器', + path: '/flutter-scene-3d', + subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Android is view-only; Windows and Android host evidence remains pending. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.iOS, AppTargetPlatform.web}, + ), + builder: (context) => const FlutterScene3dEntry(), + ), + + // 弹窗与列表 + ModuleEntry( + title: '弹窗合集', + path: '/popup-widgets', + subtitle: '全面展示 Flutter 中的对话框、底部抽屉、菜单等弹窗类型', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['AlertDialog', 'BottomSheet', 'Overlay', 'ContextMenu'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const PopWidgetEntry(), + ), + ModuleEntry( + title: '弹窗与列表交互', + path: '/popup-list-interaction', + subtitle: 'Flutter 弹窗组件与二维滚动表格的综合演示', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['Dialog', 'BottomSheet', 'Overlay', 'TableView', '二维滚动'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const PopupListInteractionEntry(), + routes: PopupListInteractionRoutes.routes, + ), + ModuleEntry( + title: '二维滚动表格', + path: '/scroll-table', + subtitle: '使用 two_dimensional_scrollables 实现固定表头的表格', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['TableView', '固定表头', '二维滚动'], + estimatedMinutes: 15, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const ScrollTableEntry(), + ), + ModuleEntry( + title: 'Overlay 跟随方案对照组', + path: '/overlay-compare', + subtitle: '对比 CompositedTransformFollower 与 markNeedsBuild 两种浮层跟随方案', + category: ModuleCategory.popupTable, + difficulty: Difficulty.intermediate, + concepts: [ + 'Overlay', + 'LayerLink', + 'CompositedTransformFollower', + 'markNeedsBuild', + 'ScrollController', + ], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(), + builder: (context) => const OverlayFollowCompareEntry(), + ), + + // 网络与平台 + ModuleEntry( + title: 'Dio 拦截器链路', + path: '/dio-interceptor', + subtitle: 'Auth/Error/Retry/Log 拦截器 + 本地 Mock Server 实战', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['Dio', '拦截器', 'Token 刷新', 'Mock Server', '重试机制'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Web uses an in-memory Dio adapter; native hosts keep the localhost mock server. + platformSupport: const ModulePlatformSupport(), + builder: (context) => const InterceptorTestEntry(), + routes: InterceptorTestRoutes.routes, + ), + ModuleEntry( + title: 'USB 设备检测', + path: '/usb-detector', + subtitle: 'Android USB 设备检测与状态监控', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + // Disabled on every target until the module is reframed as a concrete OTG workflow. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, + ), + builder: (context) => const UsbDetectorEntry(), + ), + ModuleEntry( + title: '文件选择器', + path: '/file-picker', + subtitle: '复用 file_picker_bridge 中台能力,演示扩展过滤、取消分支与平台差异', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + // Android uses file_selector; Web exposes only the selected filename. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.iOS}, + ), + builder: (context) => const FilePickerEntry(), + ), + ModuleEntry( + title: '在线视频播放', + path: '/online-video-player', + subtitle: '使用 video_player 播放在线 HTTP 视频流并操控播放参数', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: [ + 'video_player', + '平台播放器', + 'HTTP 流', + '播放控制', + '倍速', + 'Controller 生命周期', + ], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Web uses same-origin media; native targets use their registered backends. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.iOS}, + ), + builder: (context) => const OnlineVideoPlayerEntry(), + ), + ModuleEntry( + title: '网页容器与跨平台导航', + path: '/webview', + subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + // Android/macOS use webview_flutter; Windows uses WebView2. + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.iOS, AppTargetPlatform.web}, + ), + builder: (context) => const WebViewEntry(), + ), +]; diff --git a/apps/flutter_forge/lib/module_registry/module_platform_support.dart b/apps/flutter_forge/lib/module_registry/module_platform_support.dart new file mode 100644 index 0000000..4cf1881 --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/module_platform_support.dart @@ -0,0 +1,14 @@ +import 'app_platform_snapshot.dart'; + +/// Declares where a module is available without coupling routing to a host. +class ModulePlatformSupport { + const ModulePlatformSupport({this.excludedPlatforms = const {}}); + + /// Product target platforms that this module explicitly does not support. + final Set excludedPlatforms; + + bool supports(AppPlatformSnapshot snapshot) { + return snapshot.isProductTarget && + !excludedPlatforms.contains(snapshot.platform); + } +} diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 7c9bb6b..24a66ac 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -10,7 +10,18 @@ }, "route": "/isolate-basic", "category": "async", - "supports_web": false, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index 8ead482..5301ab3 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -10,7 +10,18 @@ }, "route": "/isolate-stream", "category": "async", - "supports_web": false, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md index aaa44dc..14fef40 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/stream-subscription", "category": "async", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index 0acab03..b32b45a 100644 --- a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/debounce-throttle", "category": "basic", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md index 1aa12a4..a38789b 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/microtask", "category": "basic", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md index 270f77e..05ebe51 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/tree-state", "category": "basic", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 9b289c2..775e521 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -10,7 +10,16 @@ }, "route": "/dio-interceptor", "category": "platform", - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md index 382eb92..23680d4 100644 --- a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md @@ -10,11 +10,18 @@ }, "route": "/file-picker", "category": "platform", - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md index e5e89b9..49f767e 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md @@ -10,11 +10,18 @@ }, "route": "/online-video-player", "category": "platform", - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart b/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart index ca9d6b9..37bf4ea 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart @@ -9,118 +9,91 @@ class VideoPlayerControls extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - Row( + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 520; + return Column( children: [ - ValueListenableBuilder( - valueListenable: adapter.uiState, - builder: (context, state, child) { - if (state == PlayerUiState.loading) { - return const SizedBox.square( - dimension: 48, - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - final isPlaying = state == PlayerUiState.playing; - return IconButton( - key: const Key('video-play-pause'), - tooltip: isPlaying ? '暂停' : '播放', - onPressed: state == PlayerUiState.error - ? null - : adapter.togglePlayPause, - icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), - ); - }, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: adapter.duration, - builder: (context, total, child) { - return ValueListenableBuilder( - valueListenable: adapter.position, - builder: (context, current, child) { - final maxSeconds = total.inMilliseconds.toDouble(); - final currentMilliseconds = current.inMilliseconds - .toDouble() - .clamp(0.0, maxSeconds == 0 ? 0.0 : maxSeconds); - return Row( - children: [ - Text( - '${_formatDuration(current)} / ' - '${_formatDuration(total)}', - ), - Expanded( - child: Slider( - key: const Key('video-seek'), - value: currentMilliseconds, - max: maxSeconds == 0 ? 1 : maxSeconds, - onChanged: maxSeconds == 0 - ? null - : (value) => adapter.seek( - Duration(milliseconds: value.round()), - ), + Row( + children: [ + ValueListenableBuilder( + valueListenable: adapter.uiState, + builder: (context, state, child) { + if (state == PlayerUiState.loading) { + return const SizedBox.square( + dimension: 48, + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final isPlaying = state == PlayerUiState.playing; + return IconButton( + key: const Key('video-play-pause'), + tooltip: isPlaying ? '暂停' : '播放', + onPressed: state == PlayerUiState.error + ? null + : adapter.togglePlayPause, + icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), + ); + }, + ), + Expanded( + child: _Timeline(adapter: adapter, compact: compact), + ), + ValueListenableBuilder( + valueListenable: adapter.rate, + builder: (context, rate, child) { + return DropdownButton( + key: const Key('video-rate'), + value: rate, + onChanged: (value) { + if (value != null) adapter.setRate(value); + }, + items: const [0.5, 1.0, 1.5, 2.0] + .map( + (value) => DropdownMenuItem( + value: value, + child: Text('${value.toStringAsFixed(1)}x'), ), - ), - ], + ) + .toList(), + ); + }, + ), + ], + ), + if (compact) _SeekBar(adapter: adapter), + Row( + children: [ + const Icon(Icons.volume_up), + Expanded( + child: ValueListenableBuilder( + valueListenable: adapter.volume, + builder: (context, volume, child) { + return Slider( + key: const Key('video-volume'), + value: volume, + onChanged: adapter.setVolume, ); }, - ); - }, - ), - ), - ValueListenableBuilder( - valueListenable: adapter.rate, - builder: (context, rate, child) { - return DropdownButton( - key: const Key('video-rate'), - value: rate, - onChanged: (value) { - if (value != null) adapter.setRate(value); + ), + ), + ValueListenableBuilder( + valueListenable: adapter.volume, + builder: (context, volume, child) { + return SizedBox( + width: 48, + child: Text('${(volume * 100).round()}%'), + ); }, - items: const [0.5, 1.0, 1.5, 2.0] - .map( - (value) => DropdownMenuItem( - value: value, - child: Text('${value.toStringAsFixed(1)}x'), - ), - ) - .toList(), - ); - }, + ), + ], ), ], - ), - Row( - children: [ - const Icon(Icons.volume_up), - Expanded( - child: ValueListenableBuilder( - valueListenable: adapter.volume, - builder: (context, volume, child) { - return Slider( - key: const Key('video-volume'), - value: volume, - onChanged: adapter.setVolume, - ); - }, - ), - ), - ValueListenableBuilder( - valueListenable: adapter.volume, - builder: (context, volume, child) { - return SizedBox( - width: 48, - child: Text('${(volume * 100).round()}%'), - ); - }, - ), - ], - ), - ], + ); + }, ); } @@ -130,3 +103,69 @@ class VideoPlayerControls extends StatelessWidget { return '$minutes:$seconds'; } } + +class _Timeline extends StatelessWidget { + const _Timeline({required this.adapter, required this.compact}); + + final VideoPlayerAdapter adapter; + final bool compact; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: adapter.duration, + builder: (context, total, child) { + return ValueListenableBuilder( + valueListenable: adapter.position, + builder: (context, current, child) { + final label = Text( + '${VideoPlayerControls._formatDuration(current)} / ' + '${VideoPlayerControls._formatDuration(total)}', + ); + if (compact) return Center(child: label); + return Row( + children: [ + label, + Expanded(child: _SeekBar(adapter: adapter)), + ], + ); + }, + ); + }, + ); + } +} + +class _SeekBar extends StatelessWidget { + const _SeekBar({required this.adapter}); + + final VideoPlayerAdapter adapter; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: adapter.duration, + builder: (context, total, child) { + return ValueListenableBuilder( + valueListenable: adapter.position, + builder: (context, current, child) { + final maxMilliseconds = total.inMilliseconds.toDouble(); + final currentMilliseconds = current.inMilliseconds.toDouble().clamp( + 0.0, + maxMilliseconds == 0 ? 0.0 : maxMilliseconds, + ); + return Slider( + key: const Key('video-seek'), + value: currentMilliseconds, + max: maxMilliseconds == 0 ? 1 : maxMilliseconds, + onChanged: maxMilliseconds == 0 + ? null + : (value) => + adapter.seek(Duration(milliseconds: value.round())), + ); + }, + ); + }, + ); + } +} diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md index fd98ec6..7285575 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -10,9 +10,22 @@ }, "route": "/usb-detector", "category": "platform", - "supported_platforms": [ - "android" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md index 6a00bb3..4da1a8f 100644 --- a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md @@ -10,11 +10,19 @@ }, "route": "/webview", "category": "platform", - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS", + "web" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index 7e57afa..7dabf02 100644 --- a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/overlay-compare", "category": "popup_table", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index f94653c..2e0e78b 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/popup-list-interaction", "category": "popup_table", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index ff5903e..40c21b1 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/popup-widgets", "category": "popup_table", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index 4fddfaa..df7e809 100644 --- a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/scroll-table", "category": "popup_table", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index 2df4a04..5c594a1 100644 --- a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/flutter-ioc", "category": "state", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md index 003f27d..726cbe0 100644 --- a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/local-persistence", "category": "state", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md index b8226d2..6044e52 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/status-management", "category": "state", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index d02bd55..b289d22 100644 --- a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/adsorption-line", "category": "ui", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "pages", diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md index bb94f00..89d7469 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/download-animation", "category": "ui", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md index 40bd067..7f52b90 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md @@ -10,9 +10,19 @@ }, "route": "/flutter-scene-3d", "category": "ui", - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "iOS", + "web" + ] + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart index 069cf6a..cfd027e 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart @@ -1,10 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'module_root.dart'; +import 'scene_interaction_mode.dart'; class FlutterScene3dEntry extends StatelessWidget { const FlutterScene3dEntry({super.key}); @override - Widget build(BuildContext context) => const FlutterScene3dPage(); + Widget build(BuildContext context) => FlutterScene3dPage( + interactionMode: SceneInteractionPolicy.resolve(defaultTargetPlatform), + ); } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart index 98b150d..5d9fa1c 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart @@ -1,12 +1,25 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; +import 'scene_camera_controller.dart'; +import 'scene_interaction_mode.dart'; import 'scene_runtime.dart'; +import 'scene_selection_controller.dart'; class FlutterScene3dPage extends StatefulWidget { - const FlutterScene3dPage({super.key, this.runtime}); + const FlutterScene3dPage({ + super.key, + this.runtime, + this.interactionMode = SceneInteractionMode.interactive, + }); final SceneDemoRuntime? runtime; + final SceneInteractionMode interactionMode; @override State createState() => _FlutterScene3dPageState(); @@ -20,21 +33,25 @@ class _FlutterScene3dPageState extends State { @override Widget build(BuildContext context) { return LearningScaffold( - title: 'Flutter Scene 3D 入门', + title: '教学型 3D 查看器', interactiveDemo: _SceneDemo( runtime: _runtime, initialization: _initialization, + interactionMode: widget.interactionMode, ), sections: const [ LearningObjectives( objectives: [ '理解 Scene、Node、Mesh 与 PerspectiveCamera 的最小协作关系', - '使用 SceneView 自带帧循环实现环绕观察、缩放、暂停与重置', + '比较离散按钮与拖拽、滚轮、键盘共同驱动的轨道相机', + '使用方向缩略图、扩展缩放和实时状态保持空间定位感', + '使用屏幕射线选择最近部件,并将镜头平滑聚焦到命中对象', + '实现自动巡展、手动接管、惯性滑行与低动态观察模式', '把快速演进的 flutter_scene API 隔离在模块本地运行时边界', ], ), ConceptChips( - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'Camera'], + concepts: ['Flutter GPU', 'Scene', 'Raycast', 'Node', 'Camera'], ), CodeSnippetCard( title: '一个长方体的最小场景', @@ -52,6 +69,8 @@ SceneView(scene, cameraBuilder: buildOrbitCamera);''', CommonPitfalls( pitfalls: [ '不要在 build 中重复创建 GPU 几何体或材质。', + '不要让自动巡展在使用者接管后自行恢复,避免相机争夺控制权。', + '缩略图只表达方向与缩放,不应重复创建第二个 GPU 场景。', '启用 Flutter GPU 只是主机前提,不能代替逐平台运行证据。', '首课刻意不加入 GLB、物理、着色器、后处理或场景编辑。', ], @@ -62,120 +81,590 @@ SceneView(scene, cameraBuilder: buildOrbitCamera);''', } class _SceneDemo extends StatefulWidget { - const _SceneDemo({required this.runtime, required this.initialization}); + const _SceneDemo({ + required this.runtime, + required this.initialization, + required this.interactionMode, + }); final SceneDemoRuntime runtime; final Future initialization; + final SceneInteractionMode interactionMode; @override State<_SceneDemo> createState() => _SceneDemoState(); } class _SceneDemoState extends State<_SceneDemo> { - void _change(void Function() action) => setState(action); + final FocusNode _focusNode = FocusNode(debugLabel: '3d-viewer-controls'); + final GlobalKey _sceneSurfaceKey = GlobalKey(); + bool? _lastReducedMotion; + + SceneCameraController get _camera => widget.runtime.camera; + bool get _isInteractive => + widget.interactionMode == SceneInteractionMode.interactive; + + @override + void initState() { + super.initState(); + _camera.addListener(_onCameraChanged); + widget.runtime.selection.addListener(_onCameraChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + if (_lastReducedMotion != reducedMotion) { + _lastReducedMotion = reducedMotion; + _camera.setReducedMotion(reducedMotion); + } + } + + @override + void dispose() { + _camera.removeListener(_onCameraChanged); + widget.runtime.selection.removeListener(_onCameraChanged); + _focusNode.dispose(); + super.dispose(); + } + + void _onCameraChanged() { + if (mounted) setState(() {}); + } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('交互长方体', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - SizedBox( - height: 320, - child: FutureBuilder( - future: widget.initialization, - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center( - key: Key('scene-error'), - child: Text('3D 场景初始化失败,请确认 Flutter GPU 已启用'), - ); - } - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - key: Key('scene-loading'), - child: Column( - mainAxisSize: MainAxisSize.min, + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _isInteractive ? _handleKeyEvent : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('设备部件检查', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + SizedBox( + height: 320, + child: FutureBuilder( + future: widget.initialization, + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center( + key: Key('scene-error'), + child: Text('3D 场景初始化失败,请确认 Flutter GPU 已启用'), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + key: Key('scene-loading'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text('正在准备 3D 场景'), + ], + ), + ); + } + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, children: [ - CircularProgressIndicator(), - SizedBox(height: 12), - Text('正在准备 3D 场景'), + Listener( + onPointerDown: _isInteractive + ? (_) => _focusNode.requestFocus() + : null, + onPointerSignal: (event) { + if (_isInteractive && + event is PointerScrollEvent && + _camera.enhancedMode) { + _focusNode.requestFocus(); + _camera.beginInteraction(); + _camera.zoomBy(event.scrollDelta.dy * 0.004); + } + }, + child: KeyedSubtree( + key: const Key('scene-interaction-surface'), + child: GestureDetector( + key: _sceneSurfaceKey, + behavior: HitTestBehavior.opaque, + onTapUp: _isInteractive + ? (details) { + final box = + _sceneSurfaceKey.currentContext + ?.findRenderObject() + as RenderBox?; + if (box != null) { + widget.runtime.selectAt( + details.localPosition, + box.size, + ); + } + } + : null, + onPanStart: _isInteractive + ? (_) => _camera.beginInteraction() + : null, + onPanUpdate: _isInteractive + ? (details) => _camera.dragBy(details.delta) + : null, + onPanEnd: _isInteractive + ? (details) => _camera.endInteraction( + details.velocity.pixelsPerSecond, + ) + : null, + child: widget.runtime.buildView(), + ), + ), + ), + if (_isInteractive) + Positioned( + top: 12, + right: 12, + child: _OrientationThumbnail(camera: _camera), + ), ], ), ); - } - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: widget.runtime.buildView(), - ); - }, + }, + ), ), - ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (context, constraints) { - final controls = _controls(); - if (constraints.maxWidth >= 620) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: controls - .map( - (control) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: control, - ), - ) - .toList(), - ); - } - return Wrap( + const SizedBox(height: 12), + if (!_isInteractive) + const Card( + key: Key('android-view-only-notice'), + child: Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Android 第一阶段为只读查看:保留自动巡展,暂不开放手势、选择与聚焦。', + textAlign: TextAlign.center, + ), + ), + ), + if (_isInteractive) + Wrap( alignment: WrapAlignment.center, spacing: 8, - runSpacing: 8, - children: controls, - ); - }, - ), - const SizedBox(height: 8), - Text( - '相机距离:${widget.runtime.zoom.toStringAsFixed(1)}', - textAlign: TextAlign.center, - ), - ], + children: [ + ChoiceChip( + key: const Key('basic-mode'), + label: const Text('基础模式'), + selected: !_camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(false), + ), + ChoiceChip( + key: const Key('enhanced-mode'), + label: const Text('增强模式'), + selected: _camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(true), + ), + ], + ), + if (_isInteractive) const SizedBox(height: 8), + if (_isInteractive) + Text( + _camera.enhancedMode + ? '拖拽环绕 · 滚轮缩放 · 方向键与 +/- 控制' + : '使用下方按钮观察离散控制效果', + textAlign: TextAlign.center, + ), + if (_isInteractive) const SizedBox(height: 12), + if (_isInteractive) + LayoutBuilder( + builder: (context, constraints) { + final controls = _controls(); + if (constraints.maxWidth >= 620) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: controls + .map( + (control) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: control, + ), + ) + .toList(), + ); + } + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: controls, + ); + }, + ), + const SizedBox(height: 8), + _CameraStatus(camera: _camera), + if (_isInteractive) const SizedBox(height: 8), + if (_isInteractive) + _SelectionPanel( + selection: widget.runtime.selection, + onFocus: widget.runtime.focusSelection, + onClear: widget.runtime.clearSelection, + ), + ], + ), ); } List _controls() => [ IconButton.filledTonal( tooltip: '向左环绕', - onPressed: () => _change(() => widget.runtime.orbitBy(-0.25)), + onPressed: () => _camera.orbitBy(-0.25), icon: const Icon(Icons.rotate_left), ), IconButton.filledTonal( tooltip: '向右环绕', - onPressed: () => _change(() => widget.runtime.orbitBy(0.25)), + onPressed: () => _camera.orbitBy(0.25), icon: const Icon(Icons.rotate_right), ), IconButton.filledTonal( tooltip: '拉近相机', - onPressed: () => _change(() => widget.runtime.zoomBy(-0.4)), + onPressed: () => _camera.animateZoomBy(-0.4), icon: const Icon(Icons.zoom_in), ), IconButton.filledTonal( tooltip: '拉远相机', - onPressed: () => _change(() => widget.runtime.zoomBy(0.4)), + onPressed: () => _camera.animateZoomBy(0.4), icon: const Icon(Icons.zoom_out), ), IconButton.filledTonal( - tooltip: widget.runtime.isPaused ? '继续环绕' : '暂停环绕', - onPressed: () => _change(widget.runtime.togglePaused), - icon: Icon(widget.runtime.isPaused ? Icons.play_arrow : Icons.pause), + tooltip: _camera.motionState == SceneMotionState.paused ? '继续环绕' : '暂停环绕', + onPressed: _camera.togglePaused, + icon: Icon( + _camera.motionState == SceneMotionState.paused + ? Icons.play_arrow + : Icons.pause, + ), ), IconButton.filledTonal( tooltip: '重置相机', - onPressed: () => _change(widget.runtime.resetCamera), + onPressed: _camera.reset, icon: const Icon(Icons.restart_alt), ), ]; + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final isRepeat = event is KeyRepeatEvent; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowLeft) { + _camera.orbitBy(-0.15); + } else if (key == LogicalKeyboardKey.arrowRight) { + _camera.orbitBy(0.15); + } else if (key == LogicalKeyboardKey.arrowUp) { + _camera.beginInteraction(); + _camera.dragBy(const Offset(0, -12)); + } else if (key == LogicalKeyboardKey.arrowDown) { + _camera.beginInteraction(); + _camera.dragBy(const Offset(0, 12)); + } else if (key == LogicalKeyboardKey.equal || + key == LogicalKeyboardKey.numpadAdd) { + _camera.animateZoomBy(-0.3); + } else if (key == LogicalKeyboardKey.minus || + key == LogicalKeyboardKey.numpadSubtract) { + _camera.animateZoomBy(0.3); + } else if (key == LogicalKeyboardKey.space) { + if (!isRepeat) _camera.togglePaused(); + } else if (key == LogicalKeyboardKey.keyR) { + if (!isRepeat) _camera.reset(); + } else if (key == LogicalKeyboardKey.escape) { + widget.runtime.clearSelection(); + } else { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } +} + +class _SelectionPanel extends StatelessWidget { + const _SelectionPanel({ + required this.selection, + required this.onFocus, + required this.onClear, + }); + + final SceneSelectionController selection; + final VoidCallback onFocus; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final selected = selection.selected; + if (selected == null) { + return const Text('点击部件查看命中信息;拖拽仍用于环绕观察', textAlign: TextAlign.center); + } + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + Text(selected.label, style: Theme.of(context).textTheme.titleSmall), + Text('命中距离 ${selected.distance.toStringAsFixed(2)}'), + Text( + '法线 (${selected.normalX.toStringAsFixed(2)}, ' + '${selected.normalY.toStringAsFixed(2)}, ' + '${selected.normalZ.toStringAsFixed(2)})', + ), + FilledButton.tonalIcon( + key: const Key('focus-selected-part'), + onPressed: onFocus, + icon: const Icon(Icons.center_focus_strong), + label: const Text('聚焦'), + ), + TextButton( + key: const Key('clear-selected-part'), + onPressed: onClear, + child: const Text('取消选择'), + ), + ], + ), + ), + ); + } +} + +class _CameraStatus extends StatefulWidget { + const _CameraStatus({required this.camera}); + + final SceneCameraController camera; + + @override + State<_CameraStatus> createState() => _CameraStatusState(); +} + +class _CameraStatusState extends State<_CameraStatus> { + late final Timer _refreshTimer; + + @override + void initState() { + super.initState(); + widget.camera.addListener(_refresh); + _refreshTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refresh(), + ); + } + + @override + void didUpdateWidget(covariant _CameraStatus oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.camera != widget.camera) { + oldWidget.camera.removeListener(_refresh); + widget.camera.addListener(_refresh); + } + } + + @override + void dispose() { + _refreshTimer.cancel(); + widget.camera.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final camera = widget.camera; + return Column( + children: [ + Text( + '状态:${_motionStateLabel(camera.motionState)} · ' + '距离:${camera.distance.toStringAsFixed(1)} · ' + '缩放:${camera.zoomScale.toStringAsFixed(2)}× · ' + '水平角:${_degrees(camera.yaw)}° · ' + '俯仰角:${_degrees(camera.pitch)}°', + key: const Key('camera-status'), + textAlign: TextAlign.center, + ), + if (camera.reducedMotion) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '已启用低动态观察模式', + key: Key('reduced-motion-status'), + textAlign: TextAlign.center, + ), + ), + ], + ); + } + + static int _degrees(double radians) => + (radians * 180 / 3.141592653589793).round(); + + static String _motionStateLabel(SceneMotionState state) => switch (state) { + SceneMotionState.autoTour => '自动巡展', + SceneMotionState.manual => '手动操控', + SceneMotionState.coasting => '惯性滑行', + SceneMotionState.paused => '已暂停', + SceneMotionState.transitioning => '平滑过渡', + }; +} + +class _OrientationThumbnail extends StatefulWidget { + const _OrientationThumbnail({required this.camera}); + + final SceneCameraController camera; + + @override + State<_OrientationThumbnail> createState() => _OrientationThumbnailState(); +} + +class _OrientationThumbnailState extends State<_OrientationThumbnail> { + late final Timer _refreshTimer; + + @override + void initState() { + super.initState(); + widget.camera.addListener(_refresh); + _refreshTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refresh(), + ); + } + + @override + void didUpdateWidget(covariant _OrientationThumbnail oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.camera != widget.camera) { + oldWidget.camera.removeListener(_refresh); + widget.camera.addListener(_refresh); + } + } + + @override + void dispose() { + _refreshTimer.cancel(); + widget.camera.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Semantics( + button: true, + label: '方向缩略图,点击重置视角', + child: Tooltip( + message: '方向缩略图 · 点击重置', + child: Material( + color: colorScheme.surface.withValues(alpha: 0.90), + elevation: 2, + borderRadius: BorderRadius.circular(10), + child: InkWell( + key: const Key('scene-orientation-thumbnail'), + onTap: widget.camera.reset, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 84, + height: 68, + child: CustomPaint( + painter: _OrientationThumbnailPainter( + yaw: widget.camera.yaw, + pitch: widget.camera.pitch, + zoomScale: widget.camera.zoomScale, + colorScheme: colorScheme, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _OrientationThumbnailPainter extends CustomPainter { + const _OrientationThumbnailPainter({ + required this.yaw, + required this.pitch, + required this.zoomScale, + required this.colorScheme, + }); + + final double yaw; + final double pitch; + final double zoomScale; + final ColorScheme colorScheme; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2 - 4); + final objectScale = + 13.0 * math.sqrt(zoomScale).clamp(0.72, 1.35).toDouble(); + final topDepth = + 5.0 + + 5.0 * + ((pitch - SceneCameraController.minPitch) / + (SceneCameraController.maxPitch - + SceneCameraController.minPitch)) + .clamp(0.0, 1.0); + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(yaw); + final front = Rect.fromCenter( + center: Offset.zero, + width: objectScale * 1.45, + height: objectScale, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(front, const Radius.circular(2)), + Paint()..color = colorScheme.primaryContainer, + ); + final top = Path() + ..moveTo(front.left, front.top) + ..lineTo(front.left + topDepth, front.top - topDepth) + ..lineTo(front.right + topDepth, front.top - topDepth) + ..lineTo(front.right, front.top) + ..close(); + canvas.drawPath( + top, + Paint()..color = colorScheme.primary.withValues(alpha: 0.55), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(front, const Radius.circular(2)), + Paint() + ..color = colorScheme.outline + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + canvas.restore(); + + final direction = Offset(math.sin(yaw), -math.cos(yaw)); + final arrowStart = center + direction * 19; + final arrowEnd = center + direction * 27; + canvas.drawLine( + arrowStart, + arrowEnd, + Paint() + ..color = colorScheme.tertiary + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _OrientationThumbnailPainter oldDelegate) => + oldDelegate.yaw != yaw || + oldDelegate.pitch != pitch || + oldDelegate.zoomScale != zoomScale || + oldDelegate.colorScheme != colorScheme; } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart new file mode 100644 index 0000000..622955e --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart @@ -0,0 +1,312 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +enum SceneMotionState { autoTour, manual, coasting, paused, transitioning } + +class SceneCameraController extends ChangeNotifier { + static const initialDistance = 4.0; + static const minDistance = 1.8; + static const maxDistance = 12.0; + static const initialPitch = 0.5; + static const minPitch = -20 * math.pi / 180; + static const maxPitch = 60 * math.pi / 180; + static const _dragSensitivity = 0.008; + static const _coastDuration = Duration(milliseconds: 650); + + double _yaw = 0; + double _pitch = initialPitch; + double _distance = initialDistance; + double _targetX = 0; + double _targetY = 0; + double _targetZ = 0; + double _coastYawVelocity = 0; + double _coastPitchVelocity = 0; + Duration _coastElapsed = Duration.zero; + Duration? _previousElapsed; + bool _enhancedMode = true; + bool _reducedMotion = false; + SceneMotionState _motionState = SceneMotionState.autoTour; + _CameraTransition? _transition; + + double get yaw => _yaw; + double get pitch => _pitch; + double get distance => _distance; + double get zoomScale => initialDistance / _distance; + double get targetX => _targetX; + double get targetY => _targetY; + double get targetZ => _targetZ; + bool get enhancedMode => _enhancedMode; + bool get reducedMotion => _reducedMotion; + SceneMotionState get motionState => _motionState; + + void tick(Duration elapsed) { + final previous = _previousElapsed; + _previousElapsed = elapsed; + if (previous == null) return; + final delta = elapsed - previous; + if (delta <= Duration.zero) return; + + final transition = _transition; + if (transition != null) { + transition.elapsed += delta; + final raw = + transition.elapsed.inMicroseconds / + transition.duration.inMicroseconds; + final t = _easeOutCubic(raw.clamp(0.0, 1.0)); + _yaw = _lerp(transition.startYaw, transition.targetYaw, t); + _pitch = _lerp(transition.startPitch, transition.targetPitch, t); + _distance = _lerp(transition.startDistance, transition.targetDistance, t); + _targetX = _lerp(transition.startTargetX, transition.targetTargetX, t); + _targetY = _lerp(transition.startTargetY, transition.targetTargetY, t); + _targetZ = _lerp(transition.startTargetZ, transition.targetTargetZ, t); + if (raw >= 1) { + _transition = null; + _motionState = transition.resumeAuto && !_reducedMotion + ? SceneMotionState.autoTour + : SceneMotionState.manual; + } + return; + } + + final seconds = delta.inMicroseconds / Duration.microsecondsPerSecond; + if (_motionState == SceneMotionState.autoTour && !_reducedMotion) { + _yaw += seconds * 0.35; + } else if (_motionState == SceneMotionState.coasting) { + _coastElapsed += delta; + final progress = + _coastElapsed.inMicroseconds / _coastDuration.inMicroseconds; + if (progress >= 1) { + _motionState = SceneMotionState.manual; + _coastYawVelocity = 0; + _coastPitchVelocity = 0; + } else { + final strength = (1 - progress) * (1 - progress); + _yaw += _coastYawVelocity * seconds * strength; + _pitch = (_pitch + _coastPitchVelocity * seconds * strength).clamp( + minPitch, + maxPitch, + ); + } + } + } + + void beginInteraction() { + if (!_enhancedMode) return; + _cancelMotion(); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void dragBy(Offset delta) { + if (!_enhancedMode) return; + _yaw -= delta.dx * _dragSensitivity; + _pitch = (_pitch - delta.dy * _dragSensitivity).clamp(minPitch, maxPitch); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void endInteraction(Offset velocity) { + if (!_enhancedMode || _reducedMotion) return; + final angularVelocity = Offset( + -velocity.dx * _dragSensitivity, + -velocity.dy * _dragSensitivity, + ); + if (angularVelocity.distance < 0.35) return; + _coastYawVelocity = angularVelocity.dx.clamp(-4.0, 4.0); + _coastPitchVelocity = angularVelocity.dy.clamp(-3.0, 3.0); + _coastElapsed = Duration.zero; + _motionState = SceneMotionState.coasting; + notifyListeners(); + } + + void orbitBy(double radians) { + _cancelMotion(); + if (_enhancedMode && !_reducedMotion) { + _animateTo( + yaw: _yaw + radians, + duration: const Duration(milliseconds: 180), + ); + } else { + _yaw += radians; + _motionState = SceneMotionState.manual; + } + notifyListeners(); + } + + void zoomBy(double delta) { + _cancelMotion(); + _distance = (_distance + delta).clamp(minDistance, maxDistance); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void animateZoomBy(double delta) { + _cancelMotion(); + final target = (_distance + delta).clamp(minDistance, maxDistance); + if (_enhancedMode && !_reducedMotion) { + _animateTo(distance: target, duration: const Duration(milliseconds: 180)); + } else { + _distance = target; + _motionState = SceneMotionState.manual; + } + notifyListeners(); + } + + void togglePaused() { + _cancelMotion(); + _motionState = _motionState == SceneMotionState.paused + ? (_reducedMotion ? SceneMotionState.manual : SceneMotionState.autoTour) + : SceneMotionState.paused; + notifyListeners(); + } + + void reset() { + _cancelMotion(); + if (_enhancedMode && !_reducedMotion) { + _animateTo( + yaw: 0, + pitch: initialPitch, + distance: initialDistance, + targetX: 0, + targetY: 0, + targetZ: 0, + duration: const Duration(milliseconds: 350), + resumeAuto: true, + ); + } else { + _yaw = 0; + _pitch = initialPitch; + _distance = initialDistance; + _targetX = 0; + _targetY = 0; + _targetZ = 0; + _motionState = _reducedMotion + ? SceneMotionState.paused + : SceneMotionState.autoTour; + } + _previousElapsed = null; + notifyListeners(); + } + + void focusOn({required double x, required double y, required double z}) { + _cancelMotion(); + if (_reducedMotion) { + _targetX = x; + _targetY = y; + _targetZ = z; + _distance = 3; + _motionState = SceneMotionState.manual; + } else { + _animateTo( + targetX: x, + targetY: y, + targetZ: z, + distance: 3, + duration: const Duration(milliseconds: 350), + ); + } + notifyListeners(); + } + + void setEnhancedMode(bool value) { + if (_enhancedMode == value) return; + final interruptedMotion = + _motionState == SceneMotionState.transitioning || + _motionState == SceneMotionState.coasting; + _cancelMotion(); + _enhancedMode = value; + if (interruptedMotion) { + _motionState = _reducedMotion + ? SceneMotionState.paused + : SceneMotionState.manual; + } + notifyListeners(); + } + + void setReducedMotion(bool value) { + if (_reducedMotion == value) return; + _cancelMotion(); + _reducedMotion = value; + _motionState = value ? SceneMotionState.paused : SceneMotionState.autoTour; + _previousElapsed = null; + notifyListeners(); + } + + void _animateTo({ + double? yaw, + double? pitch, + double? distance, + double? targetX, + double? targetY, + double? targetZ, + required Duration duration, + bool resumeAuto = false, + }) { + _transition = _CameraTransition( + startYaw: _yaw, + targetYaw: yaw ?? _yaw, + startPitch: _pitch, + targetPitch: pitch ?? _pitch, + startDistance: _distance, + targetDistance: distance ?? _distance, + startTargetX: _targetX, + targetTargetX: targetX ?? _targetX, + startTargetY: _targetY, + targetTargetY: targetY ?? _targetY, + startTargetZ: _targetZ, + targetTargetZ: targetZ ?? _targetZ, + duration: duration, + resumeAuto: resumeAuto, + ); + _motionState = SceneMotionState.transitioning; + } + + void _cancelMotion() { + _transition = null; + _coastYawVelocity = 0; + _coastPitchVelocity = 0; + _coastElapsed = Duration.zero; + } + + static double _easeOutCubic(double value) => + 1 - math.pow(1 - value, 3).toDouble(); + static double _lerp(double start, double end, double t) => + start + (end - start) * t; +} + +class _CameraTransition { + _CameraTransition({ + required this.startYaw, + required this.targetYaw, + required this.startPitch, + required this.targetPitch, + required this.startDistance, + required this.targetDistance, + required this.startTargetX, + required this.targetTargetX, + required this.startTargetY, + required this.targetTargetY, + required this.startTargetZ, + required this.targetTargetZ, + required this.duration, + required this.resumeAuto, + }); + + final double startYaw; + final double targetYaw; + final double startPitch; + final double targetPitch; + final double startDistance; + final double targetDistance; + final double startTargetX; + final double targetTargetX; + final double startTargetY; + final double targetTargetY; + final double startTargetZ; + final double targetTargetZ; + final Duration duration; + final bool resumeAuto; + Duration elapsed = Duration.zero; +} diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart new file mode 100644 index 0000000..aebeed2 --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +enum SceneInteractionMode { interactive, viewOnly } + +class SceneInteractionPolicy { + const SceneInteractionPolicy._(); + + static SceneInteractionMode resolve(TargetPlatform platform) => + platform == TargetPlatform.android + ? SceneInteractionMode.viewOnly + : SceneInteractionMode.interactive; +} diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart index 58c4fb1..b33452e 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart @@ -4,48 +4,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'scene_camera_controller.dart'; +import 'scene_selection_controller.dart'; + /// Module-local boundary around flutter_scene's pre-1.0 API. abstract interface class SceneDemoRuntime { Future initialize(); Widget buildView(); - bool get isPaused; - - double get zoom; + SceneCameraController get camera; - void orbitBy(double radians); + SceneSelectionController get selection; - void zoomBy(double delta); + void selectAt(Offset position, Size viewSize); - void togglePaused(); + void focusSelection(); - void resetCamera(); + void clearSelection(); } class FlutterSceneRuntime implements SceneDemoRuntime { final Scene _scene = Scene(); - double _orbitOffset = 0; - double _distance = 4; - double _animatedAngle = 0; - Duration? _previousElapsed; - bool _paused = false; + final Map _parts = {}; + PerspectiveCamera? _currentCamera; + Node? _highlightedNode; @override - bool get isPaused => _paused; + final SceneCameraController camera = SceneCameraController(); @override - double get zoom => _distance; + final SceneSelectionController selection = SceneSelectionController(); @override Future initialize() async { await Scene.initializeStaticResources(); - _scene.add( - Node( - mesh: Mesh( - CuboidGeometry(vm.Vector3(1.8, 1.2, 1)), - PhysicallyBasedMaterial(), - ), + _addPart( + id: 'cuboid', + label: '机身', + position: vm.Vector3(-1.4, 0, 0), + geometry: CuboidGeometry(vm.Vector3(1.2, 1, 1)), + ); + _addPart( + id: 'sphere', + label: '传感器', + position: vm.Vector3(0, 0, 0), + geometry: SphereGeometry(radius: 0.62, segments: 24, rings: 12), + ); + _addPart( + id: 'cylinder', + label: '驱动轴', + position: vm.Vector3(1.4, 0, 0), + geometry: CylinderGeometry( + bottomRadius: 0.55, + topRadius: 0.55, + height: 1.2, + radialSegments: 24, ), ); } @@ -55,44 +69,90 @@ class FlutterSceneRuntime implements SceneDemoRuntime { return SceneView( _scene, cameraBuilder: (elapsed) { - final previous = _previousElapsed; - _previousElapsed = elapsed; - if (!_paused && previous != null) { - _animatedAngle += - (elapsed - previous).inMicroseconds / - Duration.microsecondsPerSecond * - 0.35; - } - final angle = _animatedAngle + _orbitOffset; - return PerspectiveCamera( + camera.tick(elapsed); + final horizontalDistance = math.cos(camera.pitch) * camera.distance; + final target = vm.Vector3( + camera.targetX, + camera.targetY, + camera.targetZ, + ); + final result = PerspectiveCamera( position: vm.Vector3( - math.sin(angle) * _distance, - 2.2, - -math.cos(angle) * _distance, + target.x + math.sin(camera.yaw) * horizontalDistance, + target.y + math.sin(camera.pitch) * camera.distance, + target.z - math.cos(camera.yaw) * horizontalDistance, ), - target: vm.Vector3.zero(), + target: target, ); + _currentCamera = result; + return result; }, ); } - @override - void orbitBy(double radians) => _orbitOffset += radians; + void _addPart({ + required String id, + required String label, + required vm.Vector3 position, + required Geometry geometry, + }) { + final node = Node( + name: '$id|$label', + mesh: Mesh(geometry, PhysicallyBasedMaterial()), + )..position = position; + _parts[id] = node; + _scene.add(node); + } @override - void zoomBy(double delta) { - _distance = (_distance + delta).clamp(2.4, 7).toDouble(); + void selectAt(Offset position, Size viewSize) { + final activeCamera = _currentCamera; + if (activeCamera == null) return; + final hit = _scene.raycast( + activeCamera.screenPointToRay(position, viewSize), + ); + if (hit == null) { + clearSelection(); + return; + } + final fields = hit.node.name.split('|'); + if (fields.length != 2 || !_parts.containsKey(fields.first)) { + clearSelection(); + return; + } + _highlightedNode?.highlightColor = null; + _highlightedNode = hit.node..highlightColor = vm.Vector4(0.18, 0.65, 1, 1); + final center = hit.node.position; + selection.select( + ScenePartHit( + id: fields.first, + label: fields.last, + centerX: center.x, + centerY: center.y, + centerZ: center.z, + distance: hit.distance, + normalX: hit.worldNormal.x, + normalY: hit.worldNormal.y, + normalZ: hit.worldNormal.z, + ), + ); } @override - void togglePaused() => _paused = !_paused; + void focusSelection() { + final selected = selection.selected; + if (selected == null) return; + camera.focusOn( + x: selected.centerX, + y: selected.centerY, + z: selected.centerZ, + ); + } @override - void resetCamera() { - _orbitOffset = 0; - _animatedAngle = 0; - _distance = 4; - _previousElapsed = null; - _paused = false; + void clearSelection() { + _highlightedNode?.highlightColor = null; + _highlightedNode = null; + selection.clear(); } } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart new file mode 100644 index 0000000..bc9d912 --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +class ScenePartHit { + const ScenePartHit({ + required this.id, + required this.label, + required this.centerX, + required this.centerY, + required this.centerZ, + required this.distance, + required this.normalX, + required this.normalY, + required this.normalZ, + }); + + final String id; + final String label; + final double centerX; + final double centerY; + final double centerZ; + final double distance; + final double normalX; + final double normalY; + final double normalZ; +} + +class SceneSelectionController extends ChangeNotifier { + ScenePartHit? _selected; + + ScenePartHit? get selected => _selected; + + void select(ScenePartHit hit) { + _selected = hit; + notifyListeners(); + } + + void clear() { + if (_selected == null) return; + _selected = null; + notifyListeners(); + } +} diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md index 4fbf423..a2b8b7d 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md @@ -10,6 +10,16 @@ }, "route": "/font-picker", "category": "ui", + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index 75e0b5c..65d8b68 100644 --- a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -10,9 +10,21 @@ }, "route": "/gcode-visualizer", "category": "ui", - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "web", + "windows" + ] + }, "entrypoints": [ "module_entry.dart", "pages", diff --git a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift index 0154f02..600635d 100644 --- a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import desktop_multi_window import device_info_plus import file_selector_macos import shared_preferences_foundation +import url_launcher_macos import video_player_avfoundation import webview_flutter_wkwebview @@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/apps/flutter_forge/macos/Podfile.lock b/apps/flutter_forge/macos/Podfile.lock index 6e23284..b3b6287 100644 --- a/apps/flutter_forge/macos/Podfile.lock +++ b/apps/flutter_forge/macos/Podfile.lock @@ -9,6 +9,8 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS @@ -22,6 +24,7 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -36,6 +39,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos video_player_avfoundation: :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin webview_flutter_wkwebview: @@ -47,6 +52,7 @@ SPEC CHECKSUMS: file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 FlutterMacOS: c232990155153907050900a2e175c7773903ba4e shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d diff --git a/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift b/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift index 74d5506..26aa1b9 100644 --- a/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift +++ b/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift @@ -7,6 +7,7 @@ class MainFlutterWindow: NSWindow { let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + self.contentMinSize = NSSize(width: 800, height: 600) precondition( flutterViewController.engine.run(withEntrypoint: nil), diff --git a/apps/flutter_forge/pubspec.yaml b/apps/flutter_forge/pubspec.yaml index 79a1bda..eb6929f 100644 --- a/apps/flutter_forge/pubspec.yaml +++ b/apps/flutter_forge/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: gcode_core: git: url: https://github.com/lizy-coding/gcode_core.git - ref: v0.2.0-dev.1 + ref: v0.2.0 file_picker_bridge: path: ../../packages/file_picker_bridge flutter_ioc_core: @@ -37,6 +37,7 @@ dependencies: video_player_web: ^2.4.0 video_player_win: ^3.2.2 shared_preferences: ^2.5.5 + url_launcher: ^6.3.2 webview_flutter: ^4.9.0 webview_windows: ^0.4.0 flutter_scene: 0.23.0 diff --git a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart index 33dc2ea..61cfdda 100644 --- a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart +++ b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart @@ -2,10 +2,39 @@ import 'package:file_picker_bridge/file_picker_bridge.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/file_picker/module_root.dart'; void main() { + test('catalog admits file picker on Android, macOS, and Windows', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/file-picker', + ); + + expect(module.platformSupport.excludedPlatforms, {AppTargetPlatform.iOS}); + for (final platform in [ + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + ]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); + } + expect( + isModuleAvailable( + module, + const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + ), + isFalse, + ); + }); + testWidgets('picked file shows its name and path', (tester) async { final picker = FakeFilePickerService( result: const PickedFile(path: '/tmp/a.gcode', name: 'a.gcode'), @@ -66,6 +95,18 @@ void main() { await tester.pump(); expect(picker.allowedExtensions, contains('.txt')); }); + + testWidgets('file picker fits a compact Android viewport', (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpModule(tester, FakeFilePickerService()); + + expect(find.byKey(const Key('pick-file-button')), findsOneWidget); + expect(tester.takeException(), isNull); + }); } Future _pumpModule(WidgetTester tester, FilePickerService picker) async { diff --git a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart index b96563e..47bf016 100644 --- a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart +++ b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/online_video_player/module_root.dart'; import 'package:flutter_forge_app/modules/platform/online_video_player/state/video_player_adapter.dart'; @@ -6,6 +9,31 @@ import 'package:flutter_forge_app/modules/platform/online_video_player/widgets/v import 'package:video_player/video_player.dart'; void main() { + test('catalog admits online video on Android, macOS, and Windows', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/online-video-player', + ); + + expect(module.platformSupport.excludedPlatforms, {AppTargetPlatform.iOS}); + for (final platform in [ + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + ]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); + } + expect( + AppRouteTable.moduleRoutesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + ).where((route) => route.path == module.path), + isNotEmpty, + ); + }); + testWidgets('controls render playback, seek, rate and volume controls', ( tester, ) async { @@ -60,6 +88,22 @@ void main() { expect(find.text('视频加载失败,请检查网络后重试'), findsOneWidget); expect(adapter.openCount, 1); }); + + testWidgets('video controls fit a compact Android viewport', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final adapter = FakeVideoPlayerAdapter(); + + await tester.pumpWidget( + MaterialApp( + home: MyHomePage(title: '在线视频播放', adapter: adapter), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('video-play-pause')), findsOneWidget); + }); } class FakeVideoPlayerAdapter implements VideoPlayerAdapter { diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart index 2750478..8dd6ef5 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/usb_detector/module_entry.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +10,32 @@ void main() { expect(const UsbDetectorEntry(), isA()); }); + test('catalog keeps USB visible but blocks every platform route', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/usb-detector', + ); + + expect(module.platformSupport.excludedPlatforms, { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }); + for (final platform in AppTargetPlatform.values) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isFalse, + ); + } + expect( + AppRouteTable.moduleRoutesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.android), + ).where((route) => route.path == module.path), + isNotEmpty, + ); + }); + testWidgets('USB detector fits a compact Android viewport', (tester) async { await tester.binding.setSurfaceSize(const Size(320, 640)); addTearDown(() => tester.binding.setSurfaceSize(null)); diff --git a/apps/flutter_forge/test/modules/platform/webview/webview_test.dart b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart index 4d4de02..edc443d 100644 --- a/apps/flutter_forge/test/modules/platform/webview/webview_test.dart +++ b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/webview/module_root.dart'; import 'webview_session_test.dart' show FakeWebViewBackend; @@ -10,10 +11,22 @@ void main() { final module = AppRouteTable.modules.singleWhere( (m) => m.path == '/webview', ); - expect(isModuleAvailable(module, TargetPlatform.android), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); - expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); - expect(isModuleAvailable(module, TargetPlatform.iOS), isFalse); + for (final platform in [ + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, + ]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); + } + for (final platform in [AppTargetPlatform.iOS, AppTargetPlatform.web]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isFalse, + ); + } }); testWidgets( 'teaching page fits compact viewport and rejects invalid navigation', diff --git a/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart b/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart index 5fbeece..b4a57fe 100644 --- a/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart +++ b/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/modules/popup_table/popup_list_interaction/module_entry.dart'; import 'package:flutter_forge_app/modules/popup_table/popup_widgets/module_root.dart'; import 'package:go_router/go_router.dart'; @@ -13,7 +14,9 @@ void main() { testWidgets('popup nested route opens from its module page', (tester) async { final router = GoRouter( initialLocation: '/popup-list-interaction', - routes: AppRouteTable.routes, + routes: AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), + ), ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); @@ -31,7 +34,9 @@ void main() { testWidgets('list nested route opens from its module page', (tester) async { final router = GoRouter( initialLocation: '/popup-list-interaction', - routes: AppRouteTable.routes, + routes: AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), + ), ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index 7d58a64..9dfb9a0 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -1,13 +1,18 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/module_root.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_camera_controller.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_interaction_mode.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_runtime.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_selection_controller.dart'; void main() { testWidgets('shows title, learning content, and semantic controls', ( @@ -19,8 +24,8 @@ void main() { ); await tester.pump(); - expect(find.text('Flutter Scene 3D 入门'), findsOneWidget); - expect(find.text('交互长方体'), findsOneWidget); + expect(find.text('教学型 3D 查看器'), findsOneWidget); + expect(find.text('设备部件检查'), findsOneWidget); expect(find.textContaining('Scene、Node、Mesh'), findsOneWidget); for (final label in ['向左环绕', '向右环绕', '拉近相机', '拉远相机', '暂停环绕', '重置相机']) { expect(find.byTooltip(label), findsOneWidget); @@ -29,6 +34,190 @@ void main() { await tester.tap(find.byTooltip('暂停环绕')); await tester.pump(); expect(find.byTooltip('继续环绕'), findsOneWidget); + expect(find.textContaining('状态:已暂停'), findsOneWidget); + }); + + testWidgets('enhanced mode supports drag takeover and mode comparison', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + + await tester.fling( + find.byKey(const Key('scene-interaction-surface')), + const Offset(80, -40), + 1200, + ); + await tester.pump(); + expect(runtime.camera.motionState, SceneMotionState.coasting); + expect(find.textContaining('状态:惯性滑行'), findsOneWidget); + + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + expect(runtime.camera.enhancedMode, isFalse); + expect(find.textContaining('离散控制效果'), findsOneWidget); + }); + + testWidgets('reduced motion disables automatic and inertial motion', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ), + ); + await tester.pump(); + + expect(runtime.camera.reducedMotion, isTrue); + expect(runtime.camera.motionState, SceneMotionState.paused); + expect(find.byKey(const Key('reduced-motion-status')), findsOneWidget); + }); + + testWidgets('Android view-only mode renders scene without controls', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp( + home: FlutterScene3dPage( + runtime: runtime, + interactionMode: SceneInteractionMode.viewOnly, + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('scene-interaction-surface')), findsOneWidget); + expect(find.byKey(const Key('android-view-only-notice')), findsOneWidget); + expect(find.byKey(const Key('basic-mode')), findsNothing); + expect(find.byKey(const Key('enhanced-mode')), findsNothing); + expect(find.byKey(const Key('scene-orientation-thumbnail')), findsNothing); + expect(find.byTooltip('暂停环绕'), findsNothing); + expect(find.textContaining('点击部件'), findsNothing); + }); + + testWidgets('basic mode blocks wheel zoom', (tester) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter( + find.byKey(const Key('scene-interaction-surface')), + ), + scrollDelta: const Offset(0, 100), + ), + ); + await tester.pump(); + + expect(runtime.camera.distance, SceneCameraController.initialDistance); + }); + + testWidgets('keyboard remains active after selecting a mode control', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(runtime.camera.yaw, closeTo(0.15, 0.001)); + }); + + testWidgets('held arrow keys consume repeat events inside the viewer', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + var escapedArrowRepeats = 0; + await tester.pumpWidget( + MaterialApp( + home: Focus( + onKeyEvent: (_, event) { + if (event is KeyRepeatEvent && + event.logicalKey == LogicalKeyboardKey.arrowLeft) { + escapedArrowRepeats++; + } + return KeyEventResult.ignored; + }, + child: FlutterScene3dPage(runtime: runtime), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(runtime.camera.yaw, closeTo(-0.30, 0.001)); + expect(escapedArrowRepeats, 0); + }); + + testWidgets('top-right orientation thumbnail previews and resets camera', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + final surface = find.byKey(const Key('scene-interaction-surface')); + final thumbnail = find.byKey(const Key('scene-orientation-thumbnail')); + + expect(thumbnail, findsOneWidget); + final surfaceRect = tester.getRect(surface); + final thumbnailRect = tester.getRect(thumbnail); + expect(thumbnailRect.top, closeTo(surfaceRect.top + 12, 0.1)); + expect(thumbnailRect.right, closeTo(surfaceRect.right - 12, 0.1)); + + await tester.tap(find.byKey(const Key('basic-mode'))); + runtime.camera.orbitBy(0.5); + expect(runtime.camera.yaw, 0.5); + await tester.tap(thumbnail); + await tester.pump(); + expect(runtime.camera.yaw, 0); + }); + + testWidgets('tap selects a part and focus targets its center', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + + await tester.tap(find.byKey(const Key('scene-interaction-surface'))); + await tester.pump(); + expect(find.text('机身'), findsOneWidget); + + final focusButton = find.byKey(const Key('focus-selected-part')); + await tester.ensureVisible(focusButton); + await tester.pump(); + await tester.tap(focusButton); + await tester.pump(); + runtime.camera.tick(Duration.zero); + runtime.camera.tick(const Duration(milliseconds: 350)); + expect(runtime.camera.targetX, -1.4); }); testWidgets('shows loading state', (tester) async { @@ -54,26 +243,31 @@ void main() { expect(find.textContaining('Flutter GPU 已启用'), findsOneWidget); }); - test('catalog registers ready macOS-only module and route', () { + test('catalog registers Android, macOS, and Windows module and route', () { final module = AppRouteTable.modules.singleWhere( (item) => item.path == '/flutter-scene-3d', ); expect(module.status, ModuleStatus.ready); - expect(module.supportedPlatforms, {TargetPlatform.macOS}); - expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); + expect(module.platformSupport.excludedPlatforms, { + AppTargetPlatform.iOS, + AppTargetPlatform.web, + }); for (final platform in [ - TargetPlatform.windows, - TargetPlatform.android, - TargetPlatform.iOS, - TargetPlatform.linux, - TargetPlatform.fuchsia, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, + AppTargetPlatform.android, ]) { - expect(isModuleAvailable(module, platform), isFalse); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } expect( - AppRouteTable.routes.where((route) => route.path == module.path), - defaultTargetPlatform == TargetPlatform.macOS ? isNotEmpty : isEmpty, + AppRouteTable.moduleRoutesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.web), + ).where((route) => route.path == module.path), + isNotEmpty, ); }); } @@ -83,14 +277,10 @@ class FakeSceneRuntime implements SceneDemoRuntime { : _initialization = initialization ?? Future.value(); final Future _initialization; - bool _paused = false; - double _zoom = 4; - @override - bool get isPaused => _paused; - + final SceneCameraController camera = SceneCameraController(); @override - double get zoom => _zoom; + final SceneSelectionController selection = SceneSelectionController(); @override Widget buildView() => @@ -100,17 +290,33 @@ class FakeSceneRuntime implements SceneDemoRuntime { Future initialize() => _initialization; @override - void orbitBy(double radians) {} + void clearSelection() => selection.clear(); @override - void resetCamera() { - _paused = false; - _zoom = 4; + void focusSelection() { + final selected = selection.selected; + if (selected == null) return; + camera.focusOn( + x: selected.centerX, + y: selected.centerY, + z: selected.centerZ, + ); } @override - void togglePaused() => _paused = !_paused; - - @override - void zoomBy(double delta) => _zoom += delta; + void selectAt(Offset position, Size viewSize) { + selection.select( + const ScenePartHit( + id: 'cuboid', + label: '机身', + centerX: -1.4, + centerY: 0, + centerZ: 0, + distance: 3.2, + normalX: 0, + normalY: 1, + normalZ: 0, + ), + ); + } } diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart new file mode 100644 index 0000000..7b25582 --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_camera_controller.dart'; + +void main() { + group('SceneCameraController', () { + test('auto tour advances until direct manipulation takes over', () { + final controller = SceneCameraController(); + + controller.tick(const Duration(seconds: 1)); + controller.tick(const Duration(seconds: 2)); + expect(controller.yaw, closeTo(0.35, 0.001)); + + controller.beginInteraction(); + controller.dragBy(const Offset(20, -10)); + final yawAfterDrag = controller.yaw; + controller.tick(const Duration(seconds: 3)); + + expect(controller.motionState, SceneMotionState.manual); + expect(controller.yaw, yawAfterDrag); + expect( + controller.pitch, + inInclusiveRange( + SceneCameraController.minPitch, + SceneCameraController.maxPitch, + ), + ); + }); + + test( + 'coasting decays within the bounded duration and new input cancels it', + () { + final controller = SceneCameraController()..beginInteraction(); + controller.endInteraction(const Offset(700, 120)); + expect(controller.motionState, SceneMotionState.coasting); + + controller.tick(Duration.zero); + controller.tick(const Duration(milliseconds: 900)); + expect(controller.motionState, SceneMotionState.manual); + + controller.endInteraction(const Offset(700, 0)); + expect(controller.motionState, SceneMotionState.coasting); + controller.zoomBy(-0.2); + expect(controller.motionState, SceneMotionState.manual); + }, + ); + + test('zoom, pitch, reduced motion, and reset remain bounded', () { + final controller = SceneCameraController()..beginInteraction(); + controller.dragBy(const Offset(0, -10000)); + controller.zoomBy(-100); + expect(controller.pitch, SceneCameraController.maxPitch); + expect(controller.distance, SceneCameraController.minDistance); + + controller.setReducedMotion(true); + controller.endInteraction(const Offset(900, 0)); + expect(controller.motionState, SceneMotionState.paused); + + controller.reset(); + expect(controller.yaw, 0); + expect(controller.pitch, SceneCameraController.initialPitch); + expect(controller.distance, SceneCameraController.initialDistance); + expect(controller.motionState, SceneMotionState.paused); + }); + + test('expanded zoom range exposes a relative zoom scale', () { + final controller = SceneCameraController(); + + controller.zoomBy(-100); + expect(controller.distance, 1.8); + expect(controller.zoomScale, closeTo(4 / 1.8, 0.001)); + + controller.zoomBy(100); + expect(controller.distance, 12); + expect(controller.zoomScale, closeTo(4 / 12, 0.001)); + }); + + test( + 'basic mode rejects direct manipulation but keeps button controls', + () { + final controller = SceneCameraController()..setEnhancedMode(false); + controller.beginInteraction(); + controller.dragBy(const Offset(100, 100)); + expect(controller.yaw, 0); + expect(controller.pitch, SceneCameraController.initialPitch); + + controller.orbitBy(0.25); + expect(controller.yaw, 0.25); + }, + ); + + test('mode changes normalize interrupted transition and coasting', () { + final controller = SceneCameraController(); + + controller.orbitBy(0.25); + expect(controller.motionState, SceneMotionState.transitioning); + controller.setEnhancedMode(false); + expect(controller.motionState, SceneMotionState.manual); + + controller.setEnhancedMode(true); + controller.beginInteraction(); + controller.endInteraction(const Offset(700, 0)); + expect(controller.motionState, SceneMotionState.coasting); + controller.setEnhancedMode(false); + expect(controller.motionState, SceneMotionState.manual); + }); + }); +} diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart new file mode 100644 index 0000000..cdca606 --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_interaction_mode.dart'; + +void main() { + test('Android uses view-only mode while desktop remains interactive', () { + expect( + SceneInteractionPolicy.resolve(TargetPlatform.android), + SceneInteractionMode.viewOnly, + ); + expect( + SceneInteractionPolicy.resolve(TargetPlatform.macOS), + SceneInteractionMode.interactive, + ); + expect( + SceneInteractionPolicy.resolve(TargetPlatform.windows), + SceneInteractionMode.interactive, + ); + }); +} diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart new file mode 100644 index 0000000..3fbaf0e --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_selection_controller.dart'; + +void main() { + test('selection can be replaced and cleared', () { + final controller = SceneSelectionController(); + const cuboid = ScenePartHit( + id: 'cuboid', + label: '机身', + centerX: -1.4, + centerY: 0, + centerZ: 0, + distance: 3.2, + normalX: 0, + normalY: 1, + normalZ: 0, + ); + + controller.select(cuboid); + expect(controller.selected, cuboid); + controller.clear(); + expect(controller.selected, isNull); + }); +} diff --git a/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart b/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart new file mode 100644 index 0000000..083c53d --- /dev/null +++ b/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Web identity wins over the browser operating system', () { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: true, + targetPlatform: TargetPlatform.macOS, + ); + + expect(snapshot.platform, AppTargetPlatform.web); + expect(snapshot.hostFamily, AppHostFamily.web); + }); + + test('maps the five product target platforms', () { + const expected = { + TargetPlatform.android: AppTargetPlatform.android, + TargetPlatform.iOS: AppTargetPlatform.iOS, + TargetPlatform.macOS: AppTargetPlatform.macOS, + TargetPlatform.windows: AppTargetPlatform.windows, + }; + + for (final entry in expected.entries) { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: false, + targetPlatform: entry.key, + ); + expect(snapshot.platform, entry.value); + expect(snapshot.isProductTarget, isTrue); + } + }); + + test('maps Linux and Fuchsia outside the product target set', () { + for (final platform in [TargetPlatform.linux, TargetPlatform.fuchsia]) { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: false, + targetPlatform: platform, + ); + expect(snapshot.platform, AppTargetPlatform.unsupported); + expect(snapshot.isProductTarget, isFalse); + } + }); +} diff --git a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart index 9b5363b..628e8f3 100644 --- a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart +++ b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart @@ -1,33 +1,44 @@ import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/module_registry/module_entry.dart'; +import 'package:flutter_forge_app/module_registry/module_platform_support.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; void main() { + const android = AppPlatformSnapshot(platform: AppTargetPlatform.android); + const macOS = AppPlatformSnapshot(platform: AppTargetPlatform.macOS); + const web = AppPlatformSnapshot(platform: AppTargetPlatform.web); + const windows = AppPlatformSnapshot(platform: AppTargetPlatform.windows); + const unsupported = AppPlatformSnapshot( + platform: AppTargetPlatform.unsupported, + ); + ModuleEntry createModule({ required String path, required ModuleCategory category, List routes = const [], - Set? supportedPlatforms, - bool? supportsWeb, - }) { - return ModuleEntry( - title: path, - path: path, - subtitle: 'test', - category: category, - difficulty: Difficulty.beginner, - concepts: const ['test'], - estimatedMinutes: 1, - status: ModuleStatus.ready, - builder: (_) => const SizedBox.shrink(), - routes: routes, - supportedPlatforms: supportedPlatforms, - supportsWeb: supportsWeb, - ); - } + Set excludedPlatforms = const {}, + }) => ModuleEntry( + title: path, + path: path, + subtitle: 'test', + category: category, + difficulty: Difficulty.beginner, + concepts: const ['test'], + estimatedMinutes: 1, + status: ModuleStatus.ready, + builder: (_) => const SizedBox.shrink(), + routes: routes, + platformSupport: ModulePlatformSupport( + excludedPlatforms: excludedPlatforms, + ), + ); + + Widget unsupportedBuilder(BuildContext context, ModuleEntry module) => + const Text('unsupported'); test('filters modules without changing catalog order', () { final modules = [ @@ -35,83 +46,68 @@ void main() { createModule(path: '/ui-a', category: ModuleCategory.ui), createModule(path: '/basic-b', category: ModuleCategory.basic), ]; - - final filtered = filterModulesByCategory(modules, ModuleCategory.basic); - - expect(filtered.map((module) => module.path), ['/basic-a', '/basic-b']); + expect( + filterModulesByCategory(modules, ModuleCategory.basic).map((m) => m.path), + ['/basic-a', '/basic-b'], + ); }); test('rebases module and child paths for a category window', () { - final modules = [ - createModule( - path: '/basic-a', - category: ModuleCategory.basic, - routes: [ - GoRoute( - path: '/details', - builder: (_, __) => const SizedBox.shrink(), - ), - ], - ), - ]; - - final routes = buildCategoryRoutes(modules); - - expect(routes.single.path, 'basic-a'); - expect((routes.single.routes.single as GoRoute).path, 'details'); - }); - - test('platform-neutral modules are available by default', () { final module = createModule( - path: '/platform-neutral', + path: '/basic-a', category: ModuleCategory.basic, + routes: [ + GoRoute(path: '/details', builder: (_, __) => const SizedBox.shrink()), + ], ); - - expect(isModuleAvailable(module, TargetPlatform.android, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.macOS, true), isTrue); + final route = buildCategoryRoutes( + [module], + android, + unsupportedBuilder: unsupportedBuilder, + ).single; + expect(route.path, 'basic-a'); + expect((route.routes.single as GoRoute).path, 'details'); }); - test('Web does not inherit the browser operating system availability', () { + test('target platforms are available unless explicitly excluded', () { final module = createModule( - path: '/macos-only', - category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, - ); - - expect(isModuleAvailable(module, TargetPlatform.macOS, true), isFalse); - }); - - test('platform-restricted modules can explicitly opt in to Web', () { - final module = createModule( - path: '/web-capable', - category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, - supportsWeb: true, + path: '/platform-neutral', + category: ModuleCategory.basic, ); - - expect(isModuleAvailable(module, TargetPlatform.windows, true), isTrue); + for (final platform in [android, macOS, web, windows]) { + expect(isModuleAvailable(module, platform), isTrue); + } + expect(isModuleAvailable(module, unsupported), isFalse); }); - test('platform-restricted modules only match declared platforms', () { + test('excluded platforms are unavailable', () { final module = createModule( path: '/macos-only', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, ); - - expect(isModuleAvailable(module, TargetPlatform.macOS, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows, false), isFalse); - expect(availableModules([module], TargetPlatform.windows, false), isEmpty); + expect(isModuleAvailable(module, macOS), isTrue); + expect(isModuleAvailable(module, windows), isFalse); + expect(availableModules([module], windows), isEmpty); }); - test('unavailable modules are excluded from category routes', () { + test('unavailable modules keep a guarded category route', () { final module = createModule( path: '/windows-only', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.windows}, + excludedPlatforms: {AppTargetPlatform.android}, ); - - expect(buildCategoryRoutes([module]), isEmpty); + final route = buildCategoryRoutes( + [module], + android, + unsupportedBuilder: unsupportedBuilder, + ).single; + expect(route.path, 'windows-only'); + expect(route.routes, isEmpty); }); } diff --git a/apps/flutter_forge/test/shared/navigation_policy_test.dart b/apps/flutter_forge/test/shared/navigation_policy_test.dart index bee80de..ec7b145 100644 --- a/apps/flutter_forge/test/shared/navigation_policy_test.dart +++ b/apps/flutter_forge/test/shared/navigation_policy_test.dart @@ -1,72 +1,36 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_forge_app/app/navigation_policy.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('Android always uses in-app navigation', () { + test('compact windows use hidden navigation', () { expect( - NavigationPolicy.resolve( - platform: TargetPlatform.android, - width: 1200, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 599, sidebarExpanded: true), + AppNavigationLayout.compact, ); }); - test('iOS always uses in-app navigation', () { + test('medium windows use a navigation rail', () { expect( - NavigationPolicy.resolve( - platform: TargetPlatform.iOS, - width: 1200, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 600, sidebarExpanded: true), + AppNavigationLayout.rail, ); - }); - - test('Web always uses in-app navigation on a desktop browser', () { - expect( - NavigationPolicy.resolve( - platform: TargetPlatform.macOS, - width: 1200, - multiWindowSupported: true, - isWeb: true, - ), - CategoryNavigationMode.inApp, - ); - }); - - test('compact desktop windows use in-app navigation', () { expect( - NavigationPolicy.resolve( - platform: TargetPlatform.macOS, - width: 599, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 1023, sidebarExpanded: true), + AppNavigationLayout.rail, ); }); - test('600dp desktop windows can use separate windows', () { + test('expanded windows use a sidebar', () { expect( - NavigationPolicy.resolve( - platform: TargetPlatform.macOS, - width: 600, - multiWindowSupported: true, - ), - CategoryNavigationMode.separateWindow, + NavigationPolicy.layoutFor(width: 1024, sidebarExpanded: true), + AppNavigationLayout.sidebar, ); }); - test('unsupported multi-window hosts fall back to in-app navigation', () { + test('manual collapse keeps a wide window on the rail', () { expect( - NavigationPolicy.resolve( - platform: TargetPlatform.linux, - width: 1200, - multiWindowSupported: false, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 1440, sidebarExpanded: false), + AppNavigationLayout.rail, ); }); } diff --git a/apps/flutter_forge/test/shared/platform_route_guard_test.dart b/apps/flutter_forge/test/shared/platform_route_guard_test.dart new file mode 100644 index 0000000..e93a715 --- /dev/null +++ b/apps/flutter_forge/test/shared/platform_route_guard_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; +import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/app/unsupported_module_page.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pumpAt( + WidgetTester tester, { + required AppPlatformSnapshot platform, + required String location, + }) async { + final router = AppRouter.create(platform)..go(location); + addTearDown(router.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('unsupported module path renders the stable guard page', ( + tester, + ) async { + await pumpAt( + tester, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + location: '/file-picker', + ); + + expect(find.byType(UnsupportedModulePage), findsOneWidget); + expect(find.text('当前平台暂不支持此模块'), findsOneWidget); + expect(find.text('当前平台:iOS'), findsOneWidget); + expect(find.text('支持平台:Android、macOS、Web、Windows'), findsOneWidget); + + await tester.tap(find.text('返回模块目录')); + await tester.pumpAndSettle(); + expect(find.text('Lizy'), findsOneWidget); + }); + + testWidgets('supported module path renders its business page', ( + tester, + ) async { + await pumpAt( + tester, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.android), + location: '/file-picker', + ); + + expect(find.byType(UnsupportedModulePage), findsNothing); + expect(find.byKey(const Key('pick-file-button')), findsOneWidget); + }); +} diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 336b505..7a13727 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -1,45 +1,187 @@ import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_forge_app/app/category_window_app.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_forge_app/app/adaptive_app_shell.dart'; +import 'package:flutter_forge_app/app/creator_home_page.dart'; import 'package:flutter_forge_app/app/module_home_page.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _android = AppPlatformSnapshot(platform: AppTargetPlatform.android); void main() { - testWidgets('module home fits a 360dp viewport', (tester) async { + testWidgets('compact layout uses a drawer and creator home', (tester) async { + await _pumpShell(tester, size: const Size(360, 800)); + + expect(find.byType(NavigationRail), findsNothing); + expect(find.byKey(const ValueKey('expanded-sidebar')), findsNothing); + expect(find.text('Lizy'), findsOneWidget); + expect( + find.text('AI Native Flutter Infrastructure Engineer'), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('creator-github-link')), findsOneWidget); + expect(find.byKey(const ValueKey('profile-juejin-link')), findsOneWidget); + + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + expect(find.byType(NavigationDrawer), findsOneWidget); + expect( + find.descendant( + of: find.byType(NavigationDrawer), + matching: find.text('架构与状态'), + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('creator home stays overflow-free at 320dp', (tester) async { + await _pumpShell(tester, size: const Size(320, 700)); + + expect(find.byKey(const ValueKey('creator-home-scroll')), findsOneWidget); + expect(find.byKey(const ValueKey('start-learning')), findsOneWidget); + expect( + find.byKey(const ValueKey('home-category:platform')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('medium layout uses a navigation rail', (tester) async { + await _pumpShell(tester, size: const Size(800, 700)); + + expect(find.byKey(const ValueKey('navigation-rail')), findsOneWidget); + expect(find.byKey(const ValueKey('expanded-sidebar')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('expanded layout uses a searchable sidebar', (tester) async { + await _pumpShell(tester, size: const Size(1200, 800)); + + expect(find.byKey(const ValueKey('expanded-sidebar')), findsOneWidget); + expect(find.byKey(const ValueKey('sidebar-search')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('sidebar-search'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, '三棵树'); + await tester.pump(); + expect(find.text('三棵树与生命周期'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('slash opens title search from the web shell', (tester) async { + await _pumpShell(tester, size: const Size(1200, 800)); + + await tester.sendKeyEvent(LogicalKeyboardKey.slash); + await tester.pumpAndSettle(); + + expect(find.text('搜索模块标题'), findsOneWidget); + expect(find.byTooltip('返回'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('category content changes from one to two columns', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(700, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - size: Size(360, 800), - padding: EdgeInsets.only(top: 24, bottom: 24), - ), - child: MaterialApp( - home: ModuleHomePage(modules: AppRouteTable.modules), + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: _android, ), ), ); + expect(find.byKey(const ValueKey('category-page:basic')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.binding.setSurfaceSize(const Size(1100, 800)); + await tester.pump(); expect(tester.takeException(), isNull); - expect(find.text('Flutter 学习实验室'), findsOneWidget); }); - testWidgets('category home fits a 360dp viewport', (tester) async { + testWidgets('desktop category header presents a focused-window action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - size: Size(360, 800), - padding: EdgeInsets.only(top: 24, bottom: 24), + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.macOS, + ), ), - child: MaterialApp( - home: CategoryHomePage( - category: ModuleCategory.basic, - modules: AppRouteTable.modules, + ), + ); + + expect(find.byKey(const ValueKey('category-header:basic')), findsOneWidget); + expect(find.byKey(const ValueKey('open-window:basic')), findsOneWidget); + expect(find.text('在新窗口打开'), findsOneWidget); + expect(find.byType(FilledButton), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('focused category window omits the open-window action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.macOS, ), + focusedWindow: true, + ), + ), + ); + + expect(find.byKey(const ValueKey('open-window:basic')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('web module cards offer an explicit new-tab action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.web), ), ), ); + expect(find.byTooltip('在新标签页打开'), findsWidgets); + expect(find.byKey(const ValueKey('open-window:basic')), findsNothing); expect(tester.takeException(), isNull); - expect(find.text('基础机制'), findsOneWidget); }); } + +Future _pumpShell(WidgetTester tester, {required Size size}) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: AdaptiveAppShell( + modules: AppRouteTable.modules, + location: '/', + child: const CreatorHomePage(), + ), + ), + ); + await tester.pump(); +} diff --git a/apps/flutter_forge/test/shared/web_compatibility_test.dart b/apps/flutter_forge/test/shared/web_compatibility_test.dart index d2b89b8..efba598 100644 --- a/apps/flutter_forge/test/shared/web_compatibility_test.dart +++ b/apps/flutter_forge/test/shared/web_compatibility_test.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/dio_interceptor/module_entry.dart'; import 'package:flutter_forge_app/modules/platform/file_picker/module_entry.dart'; @@ -12,6 +13,8 @@ import 'package:flutter_forge_app/modules/ui/gcode_visualizer/module_entry.dart' import 'package:flutter_test/flutter_test.dart'; void main() { + const web = AppPlatformSnapshot(platform: AppTargetPlatform.web); + test('Web catalog keeps native-only capabilities unavailable', () { for (final path in [ '/isolate-basic', @@ -23,7 +26,7 @@ void main() { final module = AppRouteTable.modules.singleWhere( (entry) => entry.path == path, ); - expect(isModuleAvailable(module), isFalse, reason: path); + expect(isModuleAvailable(module, web), isFalse, reason: path); } }); @@ -36,8 +39,7 @@ void main() { final module = AppRouteTable.modules.singleWhere( (entry) => entry.path == path, ); - expect(isModuleAvailable(module), isTrue, reason: path); - expect(module.supportsWeb, isTrue, reason: path); + expect(isModuleAvailable(module, web), isTrue, reason: path); } }); @@ -59,14 +61,16 @@ void main() { ); for (final platform in [ - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, ]) { - expect(isModuleAvailable(module, platform, false), isTrue); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } - expect(module.supportedPlatforms, isNull); - expect(module.supportsWeb, isTrue); + expect(module.platformSupport.excludedPlatforms, isEmpty); }); testWidgets('Web entries render safe compatibility states', (tester) async { diff --git a/apps/flutter_forge/windows/runner/flutter_window.cpp b/apps/flutter_forge/windows/runner/flutter_window.cpp index 9c69cdb..032e356 100644 --- a/apps/flutter_forge/windows/runner/flutter_window.cpp +++ b/apps/flutter_forge/windows/runner/flutter_window.cpp @@ -69,6 +69,13 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, } switch (message) { + case WM_GETMINMAXINFO: { + auto* info = reinterpret_cast(lparam); + const UINT dpi = GetDpiForWindow(hwnd); + info->ptMinTrackSize.x = MulDiv(800, dpi, 96); + info->ptMinTrackSize.y = MulDiv(600, dpi, 96); + return 0; + } case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; diff --git a/docs/adr/0003-platform-module-availability.md b/docs/adr/0003-platform-module-availability.md index 61d5184..6e2faca 100644 --- a/docs/adr/0003-platform-module-availability.md +++ b/docs/adr/0003-platform-module-availability.md @@ -2,7 +2,7 @@ | 属性 | 值 | |------|-----| -| 状态 | accepted | +| 状态 | accepted;平台声明与路由注册部分被 0011 取代 | | 日期 | 2026-08-25 | | 决策者 | forest | @@ -17,3 +17,5 @@ ## 范围 第一阶段按 `TargetPlatform` 隔离,不引入独立能力矩阵或动态插件系统。 + +平台限制的反向声明、启动平台快照和不支持模块的守卫路由由 ADR 0011 接续规定。 diff --git a/docs/adr/0008-flutter-scene-platform-evidence-boundary.md b/docs/adr/0008-flutter-scene-platform-evidence-boundary.md index 263ba75..120500b 100644 --- a/docs/adr/0008-flutter-scene-platform-evidence-boundary.md +++ b/docs/adr/0008-flutter-scene-platform-evidence-boundary.md @@ -2,7 +2,7 @@ | 属性 | 值 | |------|-----| -| 状态 | accepted | +| 状态 | accepted;Windows 准入部分由 ADR 0009 取代 | | 日期 | 2026-09-13 | | 决策者 | forest | diff --git a/docs/adr/0009-flutter-scene-windows-logical-admission.md b/docs/adr/0009-flutter-scene-windows-logical-admission.md new file mode 100644 index 0000000..f74b6d9 --- /dev/null +++ b/docs/adr/0009-flutter-scene-windows-logical-admission.md @@ -0,0 +1,8 @@ +# Flutter Scene Windows 逻辑准入 + +状态:accepted。Flutter Scene 3D 从本决策起在目录与路由层同时准入 macOS 和 Windows,因为 Windows Runner 已按固定依赖要求启用 Flutter GPU,且模块逻辑不依赖 macOS 专属 API;这一准入只表示 Windows 可以进入模块,不表示 Windows 构建、GPU 首帧、高 DPI 输入或安装器验收已经通过。 + +## Consequences + +- ADR 0008 中的 Windows 目录限制由本决策取代;Android、iOS 与 Web 的限制不变。 +- Windows 主机证据继续由 flutter_scene_3d_windows_admission 独立任务管理,未完成前不得标记 Windows 平台 PASS。 diff --git a/docs/adr/0010-flutter-scene-android-view-only-admission.md b/docs/adr/0010-flutter-scene-android-view-only-admission.md new file mode 100644 index 0000000..4f979a0 --- /dev/null +++ b/docs/adr/0010-flutter-scene-android-view-only-admission.md @@ -0,0 +1,9 @@ +# Flutter Scene Android 只读准入 + +状态:accepted。Flutter Scene 3D 在 Android 首阶段只开放场景初始化和自动巡展,不开放手势、选择、聚焦或编辑能力;这样可以先隔离验证 Android Flutter GPU 渲染路径,再在后续任务中独立建设移动端操控。 + +## Consequences + +- Android 进入模块后看到明确的只读说明,桌面平台继续保留完整交互。 +- Android Flutter GPU、APK 构建和真实首帧仍需独立证据;目录准入不构成平台 PASS。 +- 本阶段不新增 Android integration 自动化流程。 diff --git a/docs/adr/0011-platform-snapshot-and-guarded-routes.md b/docs/adr/0011-platform-snapshot-and-guarded-routes.md new file mode 100644 index 0000000..24dbe85 --- /dev/null +++ b/docs/adr/0011-platform-snapshot-and-guarded-routes.md @@ -0,0 +1,12 @@ +# ADR 0011: 平台快照与守卫路由 + +状态:accepted + +Flutter Forge 在进程启动时建立不可变宿主平台快照,平台判定模型仅覆盖 Android、iOS、macOS、Web 和 Windows。模块以 `excludedPlatforms` 声明限制,目标集合内未排除即支持,目标外平台一律不支持;所有模块路径保持注册,不支持的平台进入统一说明页而不是业务页面或 404。窗口尺寸与设备能力不进入快照,平台验收也不形成额外运行时状态,以保持路由稳定并降低普通模块的声明维护成本。 + +## 后果 + +- Router、目录和导航策略共享同一启动快照,不再分别读取全局平台变量。 +- 平台类模块必须显式确认排除集合,普通模块可以省略空集合。 +- 权限、USB、GPU、文件提供器和插件运行结果由模块适配器处理,不动态增删路由。 +- ADR 0003 的“不支持模块不注册路由”和支持列表表达方式被本决策取代;学习质量状态仍与平台支持分离。 diff --git a/docs/adr/0012-adaptive-navigation-shell.md b/docs/adr/0012-adaptive-navigation-shell.md new file mode 100644 index 0000000..effb61d --- /dev/null +++ b/docs/adr/0012-adaptive-navigation-shell.md @@ -0,0 +1,11 @@ +# 自适应导航外壳与分类专注窗口 + +状态:accepted + +Flutter Forge 在所有平台共享“创作者主页—学习分类—业务模块”的信息架构,但按可用宽度选择导航外壳:小于 `600dp` 使用按需打开的移动导航,`600–1023dp` 使用 NavigationRail,`1024dp` 起使用可折叠侧栏。macOS 和 Windows 主窗口及分类专注窗口的最小尺寸为 `800 × 600`,避免原生桌面窗口退化到未经设计的紧凑状态。 + +主窗口负责发现、标题搜索和应用内学习导航;桌面多窗口不再是打开分类的默认行为,而是使用者显式选择的增强能力。分类专注窗口只承载一个分类、复用同分类已有窗口且不重复主窗口侧栏。移动端保持单窗口,使用 Drawer、首页分类入口和全屏教学页。 + +默认路由为创作者主页,只展示作者身份与 GitHub 直链;学习分类和项目内容由导航外壳承载,不在主页重复推荐,也不再建立独立“关于”页面。初版搜索只匹配模块标题,侧栏折叠偏好只在当前运行期间保留。 + +本决策取代 ADR 0005 中“桌面宽窗口打开分类时默认创建独立窗口”的交互;ADR 0005 的移动端单窗口、紧凑窗口应用内导航和多窗口生命周期边界继续有效。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e88a484..fe5fee6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,11 +8,16 @@ |------|------|------| | 0001 | 单仓布局 (Pub Workspace) | accepted | | 0002 | Agent 契约生成源 | accepted | -| 0003 | 平台模块可用性 | accepted | +| 0003 | 平台模块可用性 | accepted;平台声明与路由注册部分被 0011 取代 | | 0004 | 质量门禁策略 | proposed | | 0005 | 移动端单窗口与桌面响应式导航 | accepted | | 0006 | PC 可维护性封板与 Android 兼容轨道 | accepted | | 0007 | Web 运行能力与启动体验基线 | accepted | +| 0008 | Flutter Scene 依赖与平台证据边界 | accepted;Windows 部分被 0009 取代 | +| 0009 | Flutter Scene Windows 逻辑准入 | accepted | +| 0010 | Flutter Scene Android 只读准入 | accepted | +| 0011 | 平台快照与守卫路由 | accepted | +| 0012 | 自适应导航外壳与分类专注窗口 | accepted | ## 状态定义 diff --git a/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift index 67e81ab..f1dddbc 100644 --- a/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift +++ b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift @@ -19,6 +19,7 @@ class CustomWindow: NSWindow { defer: false) self.isReleasedWhenClosed = false + self.contentMinSize = NSSize(width: 800, height: 600) } deinit { diff --git a/packages/desktop_multi_window/windows/flutter_window.cc b/packages/desktop_multi_window/windows/flutter_window.cc index 4a199d4..f28d59e 100644 --- a/packages/desktop_multi_window/windows/flutter_window.cc +++ b/packages/desktop_multi_window/windows/flutter_window.cc @@ -69,6 +69,13 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, } switch (message) { + case WM_GETMINMAXINFO: { + auto* info = reinterpret_cast(lparam); + const UINT dpi = GetDpiForWindow(hwnd); + info->ptMinTrackSize.x = MulDiv(800, dpi, 96); + info->ptMinTrackSize.y = MulDiv(600, dpi, 96); + return 0; + } case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; diff --git a/pubspec.lock b/pubspec.lock index 957b1ff..2783983 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -356,11 +356,11 @@ packages: dependency: transitive description: path: "." - ref: "v0.2.0-dev.1" - resolved-ref: "019678664bb5891c72f904373efad3aea8238fc6" + ref: "v0.2.0" + resolved-ref: "318dd628a1e3900b0c8b90d7883983f850c7cb34" url: "https://github.com/lizy-coding/gcode_core.git" source: git - version: "0.2.0-dev.1" + version: "0.2.0" glob: dependency: transitive description: @@ -771,6 +771,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" vector_graphics: dependency: transitive description: diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 2916aa7..4256363 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -79,8 +79,8 @@ const modules = [ estimatedMinutes: 20, entry: 'IsolateTestEntry', routes: 'IsolateTestRoutes', - supportsWeb: false, - supportsWebComment: '// Safari Web validation showed no usable progress lifecycle for Isolate.spawn.', + excludedPlatforms: ['web'], + platformSupportComment: '// Safari Web validation showed no usable progress lifecycle for Isolate.spawn.', }, { category: 'async', @@ -94,8 +94,8 @@ const modules = [ concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], estimatedMinutes: 35, entry: 'IsolateStreamEntry', - supportsWeb: false, - supportsWebComment: '// Safari Web validation showed tasks stalled at zero progress.', + excludedPlatforms: ['web'], + platformSupportComment: '// Safari Web validation showed tasks stalled at zero progress.', }, { category: 'state', @@ -143,8 +143,8 @@ const modules = [ id: 'gcode_visualizer', route: '/gcode-visualizer', status: 'ready', - supportedPlatforms: ['macOS'], - supportedPlatformsComment: '// gcode_core v0.2.0-dev.1 validates macOS GPU rendering only.', + excludedPlatforms: ['android', 'iOS', 'web', 'windows'], + platformSupportComment: '// gcode_core v0.2.0-dev.1 validates macOS GPU rendering only.', depends: ['shared_learning', 'gcode_core', 'file_picker_bridge', 'module_registry'], title: 'G-code 解析与轨迹动画', subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', @@ -200,14 +200,14 @@ const modules = [ route: '/flutter-scene-3d', status: 'ready', depends: ['shared_learning', 'flutter_scene', 'vector_math', 'module_registry'], - title: 'Flutter Scene 3D 入门', - subtitle: '用一个可交互长方体理解场景、网格与相机控制', + title: '教学型 3D 查看器', + subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', difficulty: 'intermediate', - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'PerspectiveCamera'], - estimatedMinutes: 25, + concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], + estimatedMinutes: 35, entry: 'FlutterScene3dEntry', - supportedPlatforms: ['macOS'], - supportedPlatformsComment: '// Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable.', + excludedPlatforms: ['iOS', 'web'], + platformSupportComment: '// Android is view-only; Windows and Android host evidence remains pending.', }, { category: 'popup_table', @@ -275,8 +275,8 @@ const modules = [ estimatedMinutes: 35, entry: 'InterceptorTestEntry', routes: 'InterceptorTestRoutes', - supportsWeb: true, - supportsWebComment: '// Web uses an in-memory Dio adapter; native hosts keep the localhost mock server.', + excludedPlatforms: [], + platformSupportComment: '// Web uses an in-memory Dio adapter; native hosts keep the localhost mock server.', }, { category: 'platform', @@ -290,8 +290,8 @@ const modules = [ concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], estimatedMinutes: 25, entry: 'UsbDetectorEntry', - supportedPlatforms: ['android'], - supportedPlatformsComment: '// Android-only: Windows 原生插件已移除,目录显示不可用、路由不注册', + excludedPlatforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + platformSupportComment: '// Disabled on every target until the module is reframed as a concrete OTG workflow.', }, { category: 'platform', @@ -305,9 +305,8 @@ const modules = [ concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], estimatedMinutes: 20, entry: 'FilePickerEntry', - supportedPlatforms: ['macOS', 'windows'], - supportsWeb: true, - supportsWebComment: '// Web accepts one user-selected file and exposes only its filename.', + excludedPlatforms: ['iOS'], + platformSupportComment: '// Android uses file_selector; Web exposes only the selected filename.', }, { category: 'platform', @@ -321,9 +320,8 @@ const modules = [ concepts: ['video_player', '平台播放器', 'HTTP 流', '播放控制', '倍速', 'Controller 生命周期'], estimatedMinutes: 35, entry: 'OnlineVideoPlayerEntry', - supportedPlatforms: ['macOS', 'windows'], - supportsWeb: true, - supportsWebComment: '// Web uses a same-origin media asset and starts playback from a user gesture.', + excludedPlatforms: ['iOS'], + platformSupportComment: '// Web uses same-origin media; native targets use their registered backends.', }, { category: 'platform', @@ -337,12 +335,18 @@ const modules = [ concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], estimatedMinutes: 30, entry: 'WebViewEntry', - supportedPlatforms: ['android', 'macOS', 'windows'], - supportedPlatformsComment: '// Android/macOS use webview_flutter; Windows uses WebView2.', + excludedPlatforms: ['iOS', 'web'], + platformSupportComment: '// Android/macOS use webview_flutter; Windows uses WebView2.', }, ]; +for (const module of modules) { + if (module.category === 'platform' && module.excludedPlatforms === undefined) { + throw new Error(`Platform module ${module.id} must declare excludedPlatforms`); + } +} + const categoryComments = { basic: '基础机制', async: '异步并发', @@ -531,7 +535,7 @@ function writeSchema() { }, module_contract_policy: { keep_for_module_rule: true, - content: ['route', 'category', 'status', 'supported_platforms', 'supports_web', 'entrypoints', 'analysis_parent'], + content: ['route', 'category', 'status', 'platform_support', 'entrypoints', 'analysis_parent'], avoid: ['class_descriptions', 'long_file_inventory', 'natural_language_notes'], }, package_contract_policy: { @@ -554,7 +558,7 @@ function writeProjectContext() { platform: { current_hosts: ['macos', 'windows'], next_host: 'web', - target_hosts: ['android', 'macos', 'web', 'windows'], + target_hosts: ['android', 'ios', 'macos', 'web', 'windows'], }, entrypoints: { process: 'lib/main.dart', @@ -562,6 +566,7 @@ function writeProjectContext() { app: 'lib/app/app.dart', router: 'lib/app/router/app_router.dart', route_table: 'lib/app/router/app_route_table.dart', + module_manifest: 'lib/module_registry/module_manifest.dart', }, repository: { layout: 'pub_workspace', @@ -588,7 +593,7 @@ function writeProjectContext() { { id: 'module_registry', path: 'lib/module_registry', - owns: ['module_metadata', 'catalog_operations'], + owns: ['module_metadata', 'platform_snapshot', 'catalog_operations'], may_depend_on: ['flutter', 'go_router'], }, { @@ -606,7 +611,7 @@ function writeProjectContext() { ], module_contract: { required_files: ['module_entry.dart', 'AI_ANALYSIS.md'], - required_registration: 'lib/app/router/app_route_table.dart', + required_registration: 'lib/module_registry/module_manifest.dart', required_metadata: ['category', 'difficulty', 'concepts', 'estimatedMinutes', 'status', 'subtitle'], required_learning_dependency: 'shared_learning', route_path_style: 'kebab_case', @@ -620,7 +625,11 @@ function writeProjectContext() { compact_width_breakpoint_dp: 600, mobile_window_policy: 'in_app_navigation_only', web_window_policy: 'in_app_navigation_only', - web_platform_detection: 'kIsWeb_before_defaultTargetPlatform', + platform_snapshot: 'bootstrap_initialized_process_immutable', + target_platforms: ['android', 'ios', 'macos', 'web', 'windows'], + non_target_platforms: 'unsupported', + module_platform_semantics: 'target_platform_available_unless_excluded', + unsupported_route: 'stable_guarded_module_route', web_release_build: 'bash tool/build_web_release.sh', web_startup_shell: 'web/index.html + web/flutter_bootstrap.js', platform_capability_contract: 'business_neutral_interface', @@ -674,6 +683,7 @@ function writeRefactorPlan() { completed_milestones: [ 'directory_layers', 'shared_package_extraction', + 'module_manifest_route_composition', 'module_analysis_coverage', 'app_navigation_boundary', 'host_bootstrap_boundary', @@ -682,6 +692,7 @@ function writeRefactorPlan() { 'pc_window_lifecycle_baseline', 'pc_build_matrix', 'flutter_scene_3d_macos_baseline', + 'platform_snapshot_guarded_routes', ], dependency_migration: { layout: 'pub_workspace', @@ -723,7 +734,16 @@ function writeRefactorPlan() { priority: 6, status: 'pending', targets: ['lib/modules/platform/usb_detector'], - acceptance: ['no_windows_hardcode', 'android_system_info', 'error_branch_test'], + acceptance: [ + 'no_windows_hardcode', + 'android_system_info', + 'error_branch_test', + 'mobile_usb_route_disabled', + ], + evidence: [ + 'USB module remains catalog-visible but has no supported host routes', + 'generic Android USB monitoring is deferred until a concrete OTG workflow exists', + ], }, { id: 'mobile_layout_baseline', @@ -859,6 +879,175 @@ function writeRefactorPlan() { 'Windows, mobile and Web remain unavailable pending separate evidence', ], }, + { + id: 'flutter_scene_3d_camera_motion', + priority: 14, + status: 'pending', + targets: [ + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart', + ], + acceptance: [ + 'bounded_two_axis_orbit', + 'direct_manipulation_takeover', + 'bounded_inertial_coasting', + 'keyboard_and_reduced_motion', + 'macos_motion_visual_acceptance', + ], + evidence: [ + 'controller and widget automation pass for takeover, bounds, inertia, mode comparison and reduced motion', + 'expanded zoom bounds and the single-scene orientation thumbnail have controller and widget coverage', + 'macOS Debug build passes and the real window renders the updated 3D first frame', + 'macOS pointer, keyboard and motion-feel acceptance remains required for completion', + ], + }, + { + id: 'flutter_scene_3d_scene_picking', + priority: 15, + status: 'pending', + targets: [ + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + ], + acceptance: [ + 'nearest_visible_part_selection', + 'drag_does_not_select', + 'selection_highlight_and_details', + 'focus_selected_part', + 'clear_selection', + 'macos_retina_pointer_acceptance', + ], + evidence: [ + 'selection state and widget focus flow have automated coverage', + 'runtime uses screenPointToRay and Scene.raycast without physics', + 'macOS Retina pointer and rendered highlight acceptance remains required', + ], + }, + { + id: 'flutter_scene_3d_windows_admission', + priority: 16, + status: 'pending', + targets: [ + 'apps/flutter_forge/windows/runner/main.cpp', + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart', + ], + acceptance: [ + 'windows_catalog_and_route_admission', + 'desktop_3d_viewer_automated_ui_flow', + 'windows_debug_and_release_build', + 'impeller_flutter_gpu_first_frame', + 'windows_pointer_keyboard_and_dpi', + 'selection_highlight_and_focus', + 'windows_installer_launch', + ], + evidence: [ + 'Windows runner enables Flutter GPU through DartProject', + 'catalog and route contract admits macOS and Windows', + 'desktop integration flow covers navigation, camera input, selection, focus, clear and return', + 'Windows host build, GPU, DPI, interaction, and installer evidence remains required', + ], + }, + { + id: 'flutter_scene_3d_android_view_only_admission', + priority: 17, + status: 'pending', + targets: [ + 'apps/flutter_forge/android/app/src/main/AndroidManifest.xml', + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + ], + acceptance: [ + 'android_flutter_gpu_enabled', + 'android_catalog_and_route_admission', + 'android_view_only_scene', + 'android_controls_selection_and_focus_hidden', + 'android_debug_apk_build', + 'android_gpu_first_frame_manual_evidence', + ], + evidence: [ + 'Android manifest enables Flutter GPU', + 'module policy limits Android to automatic view-only rendering', + 'catalog and widget contracts cover Android admission and hidden controls', + 'Android emulator or device automation is intentionally deferred by scope', + 'Android host build and GPU first-frame evidence remains required', + ], + }, + { + id: 'android_online_video_playback', + priority: 18, + status: 'pending', + targets: [ + 'apps/flutter_forge/android/app/src/main/AndroidManifest.xml', + 'apps/flutter_forge/lib/modules/platform/online_video_player', + 'apps/flutter_forge/test/modules/platform/online_video_player', + ], + acceptance: [ + 'android_catalog_and_route_admission', + 'android_release_internet_permission', + 'compact_video_controls', + 'android_debug_apk_build', + 'android_real_video_playback', + ], + evidence: [ + 'video_player_android is registered in GeneratedPluginRegistrant', + 'main Android manifest grants INTERNET for release playback', + 'catalog and 320dp widget contracts cover Android entry', + 'Android device playback evidence remains required', + ], + }, + { + id: 'android_file_picker_admission', + priority: 19, + status: 'pending', + targets: [ + 'packages/file_picker_bridge', + 'apps/flutter_forge/lib/modules/platform/file_picker', + 'apps/flutter_forge/test/modules/platform/file_picker', + ], + acceptance: [ + 'android_catalog_and_route_admission', + 'android_file_selector_backend', + 'compact_file_picker_layout', + 'android_debug_apk_build', + 'android_real_device_pick_and_cancel', + ], + evidence: [ + 'file_picker_bridge selects file_selector on Android', + 'file_selector_android is registered in GeneratedPluginRegistrant', + 'catalog and compact widget contracts cover Android entry', + 'Android real-device document provider behavior remains required', + ], + }, + { + id: 'platform_snapshot_guarded_routes', + priority: 20, + status: 'completed', + targets: [ + 'apps/flutter_forge/lib/module_registry', + 'apps/flutter_forge/lib/app', + 'apps/flutter_forge/test/shared', + ], + acceptance: [ + 'process_immutable_platform_snapshot', + 'five_product_target_platforms', + 'excluded_platform_contract', + 'stable_guarded_module_routes', + 'unsupported_module_explanation', + 'android_debug_apk_build', + 'web_release_build', + ], + evidence: [ + 'bootstrap injects one AppPlatformSnapshot into Router and ProviderScope', + 'module contracts use target_platforms and excluded_platforms', + 'guarded route tests cover supported and unsupported module paths', + 'bare analyze and full test suite pass', + 'Android debug APK and Web release builds pass', + 'iOS is modeled but has no host directory or build evidence', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', @@ -875,8 +1064,6 @@ function writeRefactorPlan() { 'recommended_module_visual_evidence', 'flutter_scene_3d_model_loading', 'flutter_scene_3d_material_lighting', - 'flutter_scene_3d_scene_picking', - 'flutter_scene_3d_windows_admission', ], }); } @@ -884,17 +1071,19 @@ function writeRefactorPlan() { function writeModuleIndex() { writeJson('lib/AI_MODULE_INDEX.md', { schema: 'flutter_forge.agent_docs.module_index.v1', - registry: 'lib/app/router/app_route_table.dart', + registry: 'lib/module_registry/module_manifest.dart', count: modules.length, - modules: modules.map(({ category, id, route, status, depends, supportedPlatforms, supportsWeb }) => ({ + modules: modules.map(({ category, id, route, status, depends, excludedPlatforms = [] }) => ({ id, category, path: `lib/modules/${category}/${id}`, route, status, depends, - ...(supportedPlatforms ? { supported_platforms: supportedPlatforms } : {}), - ...(supportsWeb !== undefined ? { supports_web: supportsWeb } : {}), + platform_support: { + target_platforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + excluded_platforms: excludedPlatforms, + }, analysis: `lib/modules/${category}/${id}/AI_ANALYSIS.md`, })), }); @@ -908,31 +1097,30 @@ function conceptsLiteral(concepts) { } function writeRouteTable() { - const file = path.join(appRoot, 'lib/app/router/app_route_table.dart'); + const manifestFile = path.join(appRoot, 'lib/module_registry/module_manifest.dart'); const lines = []; lines.push('// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT'); lines.push(''); - lines.push("import 'package:flutter/foundation.dart';"); lines.push("import 'package:go_router/go_router.dart';"); lines.push(''); - lines.push("import '../module_home_page.dart';"); - lines.push("import '../../module_registry/module_category.dart';"); - lines.push("import '../../module_registry/module_catalog_utils.dart';"); - lines.push("import '../../module_registry/module_entry.dart';"); + lines.push("import 'app_platform_snapshot.dart';"); + lines.push("import 'module_category.dart';"); + lines.push("import 'module_entry.dart';"); + lines.push("import 'module_platform_support.dart';"); const emittedImports = new Set(); for (const [category, id, kind, gap] of routeTableImportOrder) { if (!modules.some((m) => m.category === category && m.id === id)) continue; const fileName = kind === 'routes' ? 'module_routes.dart' : 'module_entry.dart'; - lines.push(`import '../../modules/${category}/${id}/${fileName}';`); + lines.push(`import '../modules/${category}/${id}/${fileName}';`); emittedImports.add(`${category}/${id}/${fileName}`); if (gap === 'gap') lines.push(''); } for (const m of modules) { for (const fileName of ['module_entry.dart', m.routes ? 'module_routes.dart' : null].filter(Boolean)) { if (emittedImports.has(`${m.category}/${m.id}/${fileName}`)) continue; - lines.push(`import '../../modules/${m.category}/${m.id}/${fileName}';`); + lines.push(`import '../modules/${m.category}/${m.id}/${fileName}';`); } } @@ -954,7 +1142,7 @@ function writeRouteTable() { } lines.push('// ==================== 模块注册 ===================='); lines.push(''); - lines.push('final List _modules = ['); + lines.push('final List moduleManifest = ['); let previousCategory = null; for (const m of modules) { if (m.category !== previousCategory) { @@ -971,23 +1159,20 @@ function writeRouteTable() { lines.push(` concepts: ${conceptsLiteral(m.concepts)},`); lines.push(` estimatedMinutes: ${m.estimatedMinutes},`); lines.push(` status: ModuleStatus.${m.status},`); - if (m.supportsWeb !== undefined) { - if (m.supportsWebComment) { - lines.push(` ${m.supportsWebComment}`); - } - lines.push(` supportsWeb: ${m.supportsWeb},`); - } - if (m.supportedPlatforms) { - if (m.supportedPlatformsComment) { - lines.push(` ${m.supportedPlatformsComment}`); - } - const platforms = m.supportedPlatforms.map((platform) => `TargetPlatform.${platform}`); - const inlinePlatforms = ` supportedPlatforms: {${platforms.join(', ')}},`; + if (m.platformSupportComment) lines.push(` ${m.platformSupportComment}`); + const excludedPlatforms = m.excludedPlatforms ?? []; + const platforms = excludedPlatforms.map((platform) => `AppTargetPlatform.${platform}`); + if (platforms.length === 0) { + lines.push(' platformSupport: const ModulePlatformSupport(),'); + } else { + const inlinePlatforms = ` excludedPlatforms: {${platforms.join(', ')}},`; + lines.push(' platformSupport: const ModulePlatformSupport('); if (inlinePlatforms.length <= 80) { lines.push(inlinePlatforms); } else { - lines.push(' supportedPlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); + lines.push(' excludedPlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); } + lines.push(' ),'); } lines.push(` builder: (context) => const ${m.entry}(),`); if (m.subRoutesExpander) { @@ -999,27 +1184,80 @@ function writeRouteTable() { } lines.push('];'); lines.push(''); - lines.push('final List _routes = ['); - lines.push(' GoRoute('); - lines.push(" path: '/',"); - lines.push(' builder: (context, state) => ModuleHomePage(modules: _modules),'); - lines.push(' ),'); - lines.push(' for (final module in availableModules(_modules))'); - lines.push(' GoRoute('); - lines.push(' path: module.path,'); - lines.push(' builder: (context, state) => module.builder(context),'); - lines.push(' routes: module.routes,'); - lines.push(' ),'); - lines.push('];'); - lines.push(''); - lines.push('class AppRouteTable {'); - lines.push(' static List get routes => _routes;'); - lines.push(' static List get modules => _modules;'); - lines.push('}'); - lines.push(''); + + fs.mkdirSync(path.dirname(manifestFile), { recursive: true }); + fs.writeFileSync(manifestFile, lines.join('\n')); + + const file = path.join(appRoot, 'lib/app/router/app_route_table.dart'); + const routeLines = []; + routeLines.push('// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT'); + routeLines.push(''); + routeLines.push("import 'package:go_router/go_router.dart';"); + routeLines.push(''); + routeLines.push("import '../adaptive_app_shell.dart';"); + routeLines.push("import '../creator_home_page.dart';"); + routeLines.push("import '../module_home_page.dart';"); + routeLines.push("import '../unsupported_module_page.dart';"); + routeLines.push("import '../../module_registry/app_platform_snapshot.dart';"); + routeLines.push("import '../../module_registry/module_catalog_utils.dart';"); + routeLines.push("import '../../module_registry/module_entry.dart';"); + routeLines.push("import '../../module_registry/module_category.dart';"); + routeLines.push("import '../../module_registry/module_manifest.dart';"); + routeLines.push(''); + routeLines.push('List _moduleRoutesFor(AppPlatformSnapshot platform) => ['); + routeLines.push(' for (final module in moduleManifest)'); + routeLines.push(' GoRoute('); + routeLines.push(' path: module.path,'); + routeLines.push(' builder: (context, state) => isModuleAvailable(module, platform)'); + routeLines.push(' ? module.builder(context)'); + routeLines.push(' : UnsupportedModulePage(module: module, platform: platform),'); + routeLines.push(' routes: isModuleAvailable(module, platform) ? module.routes : const [],'); + routeLines.push(' ),'); + routeLines.push('];'); + routeLines.push(''); + routeLines.push('List _routesFor(AppPlatformSnapshot platform) => ['); + routeLines.push(' ShellRoute('); + routeLines.push(' builder: (context, state, child) => AdaptiveAppShell('); + routeLines.push(' modules: moduleManifest,'); + routeLines.push(' location: state.uri.path,'); + routeLines.push(' child: child,'); + routeLines.push(' ),'); + routeLines.push(' routes: ['); + routeLines.push(' GoRoute('); + routeLines.push(" path: '/',"); + routeLines.push(' builder: (context, state) => const CreatorHomePage(),'); + routeLines.push(' ),'); + routeLines.push(' GoRoute('); + routeLines.push(" path: '/category/:category',"); + routeLines.push(' builder: (context, state) {'); + routeLines.push(" final name = state.pathParameters['category'];"); + routeLines.push(' final category = ModuleCategory.values.firstWhere('); + routeLines.push(' (candidate) => candidate.name == name,'); + routeLines.push(' orElse: () => ModuleCategory.basic,'); + routeLines.push(' );'); + routeLines.push(' return CategoryHomePage('); + routeLines.push(' category: category,'); + routeLines.push(' modules: moduleManifest,'); + routeLines.push(' platform: platform,'); + routeLines.push(' );'); + routeLines.push(' },'); + routeLines.push(' ),'); + routeLines.push(' ..._moduleRoutesFor(platform),'); + routeLines.push(' ],'); + routeLines.push(' ),'); + routeLines.push('];'); + routeLines.push(''); + routeLines.push('class AppRouteTable {'); + routeLines.push(' static List routesFor(AppPlatformSnapshot platform) =>'); + routeLines.push(' _routesFor(platform);'); + routeLines.push(' static List moduleRoutesFor(AppPlatformSnapshot platform) =>'); + routeLines.push(' _moduleRoutesFor(platform);'); + routeLines.push(' static List get modules => moduleManifest;'); + routeLines.push('}'); + routeLines.push(''); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, lines.join('\n')); + fs.writeFileSync(file, routeLines.join('\n')); } function writeRootIndexes() { @@ -1062,9 +1300,9 @@ function writeLayerIndexes() { rel: 'lib/app/AI_ANALYSIS.md', id: 'flutter_forge_app.app', kind: 'app_index', - entrypoints: ['app.dart', 'app_bootstrap.dart', 'module_home_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], - owns: ['host_bootstrap', 'material_app_router', 'router', 'module_home', 'responsive_navigation_policy', 'adaptive_category_navigation', 'desktop_category_window_shell'], - depends: ['go_router', 'module_registry', 'shared/multi_window', 'modules'], + entrypoints: ['app.dart', 'app_bootstrap.dart', 'app_platform_provider.dart', 'adaptive_app_shell.dart', 'creator_home_page.dart', 'creator_profile.dart', 'module_home_page.dart', 'unsupported_module_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], + owns: ['host_bootstrap', 'platform_snapshot_injection', 'material_app_router', 'router', 'creator_home', 'module_catalog', 'title_search', 'unsupported_module_route', 'responsive_navigation_shell', 'adaptive_category_navigation', 'desktop_category_window_shell'], + depends: ['go_router', 'flutter_riverpod', 'url_launcher', 'module_registry', 'shared/multi_window', 'modules'], children: ['router/AI_ANALYSIS.md'], }); writeIndex({ @@ -1072,15 +1310,15 @@ function writeLayerIndexes() { id: 'flutter_forge_app.app.router', kind: 'router_index', entrypoints: ['app_router.dart', 'app_route_table.dart'], - owns: ['go_router_root', 'module_route_aggregation', 'module_catalog_composition'], - depends: ['app/module_home_page', 'module_registry', 'modules'], + owns: ['go_router_root', 'adaptive_shell_route', 'creator_home_route', 'category_route', 'stable_guarded_module_routes', 'module_route_aggregation', 'module_catalog_composition'], + depends: ['app/adaptive_app_shell', 'app/creator_home_page', 'app/module_home_page', 'module_registry', 'modules'], }); writeIndex({ rel: 'lib/module_registry/AI_ANALYSIS.md', id: 'flutter_forge_app.module_registry', kind: 'registry_index', - entrypoints: ['module_entry.dart', 'module_category.dart', 'module_catalog_utils.dart'], - owns: ['module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum', 'native_web_availability', 'module_catalog_filtering', 'category_route_rebasing'], + entrypoints: ['app_platform_snapshot.dart', 'module_entry.dart', 'module_category.dart', 'module_platform_support.dart', 'module_catalog_utils.dart'], + owns: ['platform_snapshot', 'product_target_platforms', 'module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum', 'excluded_platform_availability', 'module_catalog_filtering', 'category_route_rebasing'], depends: ['flutter_material', 'go_router'], }); writeIndex({ @@ -1136,7 +1374,7 @@ function writeModuleIndexes() { } function writeModuleContracts() { - for (const { category, id: module, route, status, depends, supportedPlatforms, supportsWeb } of modules) { + for (const { category, id: module, route, status, depends, excludedPlatforms = [] } of modules) { const dir = path.join(appRoot, 'lib/modules', category, module); const entrypoints = []; for (const item of ['module_entry.dart', 'module_root.dart', 'module_routes.dart']) { @@ -1157,8 +1395,10 @@ function writeModuleContracts() { }, route, category, - ...(supportedPlatforms ? { supported_platforms: supportedPlatforms } : {}), - ...(supportsWeb !== undefined ? { supports_web: supportsWeb } : {}), + platform_support: { + target_platforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + excluded_platforms: excludedPlatforms, + }, entrypoints: entrypoints.length ? entrypoints : ['module_entry.dart'], owns: ['module_entry', 'module_ui', 'module_docs'], depends, diff --git a/tool/validate_agent_docs.js b/tool/validate_agent_docs.js index ac573f6..99d38ea 100644 --- a/tool/validate_agent_docs.js +++ b/tool/validate_agent_docs.js @@ -148,11 +148,18 @@ if (moduleIndex) { if (contract.node?.status !== mod.status) { failures.push(`${mod.analysis}:index_mismatch:status`); } - if ('supports_web' in mod && typeof mod.supports_web !== 'boolean') { - failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:supports_web_not_boolean`); + const targetPlatforms = ['android', 'iOS', 'macOS', 'web', 'windows']; + if (JSON.stringify(mod.platform_support?.target_platforms) !== JSON.stringify(targetPlatforms)) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:platform_target_set_invalid`); } - if (contract.supports_web !== mod.supports_web) { - failures.push(`${mod.analysis}:index_mismatch:supports_web`); + const excludedPlatforms = mod.platform_support?.excluded_platforms; + if (!Array.isArray(excludedPlatforms)) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:excluded_platforms_not_array`); + } else if (excludedPlatforms.some((platform) => !targetPlatforms.includes(platform))) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:excluded_platform_outside_target_set`); + } + if (JSON.stringify(contract.platform_support) !== JSON.stringify(mod.platform_support)) { + failures.push(`${mod.analysis}:index_mismatch:platform_support`); } } @@ -234,7 +241,7 @@ if (moduleIndex) { } } - // ── phase 5b: single-source consistency — generate source vs index vs route table ── + // ── phase 5b: single-source consistency — generate source vs index vs manifest ── function readGenerateSourceModules() { const source = fs.readFileSync(path.join(root, 'tool/generate_agent_indexes.js'), 'utf8'); @@ -253,10 +260,20 @@ if (moduleIndex) { const generateModules = readGenerateSourceModules(); const generateById = new Map(generateModules.map((m) => [m.id, m])); + const manifestContent = fileExists('lib/module_registry/module_manifest.dart') + ? fs.readFileSync(resolveProjectPath('lib/module_registry/module_manifest.dart'), 'utf8') + : ''; const routeTableContent = fileExists('lib/app/router/app_route_table.dart') ? fs.readFileSync(resolveProjectPath('lib/app/router/app_route_table.dart'), 'utf8') : ''; + if (routeTableContent.includes("../../modules/")) { + failures.push('app_route_table.dart:must_not_import_feature_modules'); + } + if (routeTableContent && !routeTableContent.includes('moduleManifest')) { + failures.push('app_route_table.dart:missing_module_manifest_composition'); + } + for (const mod of moduleIndex.modules ?? []) { const gen = generateById.get(mod.id); if (!gen) { @@ -268,8 +285,8 @@ if (moduleIndex) { failures.push(`generate source:${mod.id}:mismatch:${key} index=${mod[key]} source=${gen[key]}`); } } - if (routeTableContent && !routeTableContent.includes(`path: '${mod.route}'`)) { - failures.push(`app_route_table.dart:missing_module_path:${mod.route}`); + if (manifestContent && !manifestContent.includes(`path: '${mod.route}'`)) { + failures.push(`module_manifest.dart:missing_module_path:${mod.route}`); } } for (const gen of generateModules) {