diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..ade640d --- /dev/null +++ b/.fvmrc @@ -0,0 +1 @@ +{"flutter": "3.44.6"} \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6763c5c..0265681 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,9 +3,7 @@ set -euo pipefail echo "🔍 Running FlutterGuard static scan..." -dart run flutterguard_cli:flutterguard scan --path . --fail-on high - -if [ $? -ne 0 ]; then +if ! dart run flutterguard_cli:flutterguard scan . --fail-on high; then echo "" echo "❌ FlutterGuard 检测到高优问题,提交被阻止。" echo " 运行 'dart run flutterguard_cli:flutterguard scan' 查看详情。" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e65e81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + quality-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Checkout pinned FlutterGuard + uses: actions/checkout@v4 + with: + repository: lizy-coding/flutterguard + ref: 9f9be84a73dc4b99a956a8529b8c334849566b03 + path: flutterguard + + - name: Prepare sibling FlutterGuard dependency + run: mv flutterguard ../flutterguard + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.6" + channel: stable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.20.2" + + - name: Bootstrap + run: | + flutter pub get + git config core.hooksPath .githooks + + - name: Agent doc generation + drift check + run: | + bash tool/generate_harness_ai_analysis.sh + git diff --exit-code -- \ + AI_ANALYSIS_SCHEMA.json \ + AI_PROJECT_CONTEXT.md \ + REFACTOR_PLAN.md \ + 'lib/**/AI_ANALYSIS.md' \ + 'lib/AI_MODULE_INDEX.md' \ + 'packages/**/AI_ANALYSIS.md' + + - name: Dart format + run: | + dart format . + git diff --exit-code -- '*.dart' + + - name: Flutter analyze + run: flutter analyze --no-fatal-infos --no-fatal-warnings + + - name: Tests + run: bash tool/test_all.sh + + - name: FlutterGuard scan + run: dart run flutterguard_cli:flutterguard scan . --fail-on high diff --git a/.hermes/README.md b/.hermes/README.md new file mode 100644 index 0000000..d0399b0 --- /dev/null +++ b/.hermes/README.md @@ -0,0 +1,163 @@ +# Hermes Agent 项目架构记录 + +> flutter_study 项目 Hermes Agent 托管架构 — 完全托管模式 +> 里程碑: online_video_player_landed | 阶段: agent_managed | 更新: 2026-08-02 + +## 项目概述 + +flutter_study 是一个 Flutter 模块化学习应用,涵盖基础机制、异步并发、状态管理、UI 动效、弹窗列表、网络平台六大分类共 18 个学习模块。通过 Dart Pub Workspace 管理 4 个内部共享包。 + +## Agent 文档体系 + +``` +flutter_study/ +├── 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 执行依据) + └── plans/ # Agent 执行计划归档 +``` + +总计: 37 个 lib AI_ANALYSIS.md + 4 个 packages AI_ANALYSIS.md = 41 个验证通过。 + +## 分层架构 + +``` +app/ ← 宿主引导 + 路由组装(禁止被 modules/ 依赖) +module_registry/ ← 模块元数据与分类(仅依赖 Flutter + go_router) +shared/ ← 业务无关能力(禁止依赖 app/ 和 modules/) +modules/ ← 学习模块叶子节点(禁止互相依赖) +packages/ ← 工作区共享包(Dart Pub Workspace,独立可测) +``` + +## 依赖方向 + +``` +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 | 人工验收依赖 | + +## 后续演进方向 + +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 执行约定 + +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_study.agent_task.v1),Codex 只执行提词,Hermes 独立验收 diff --git a/.hermes/online-video-player-macos.codex.json b/.hermes/online-video-player-macos.codex.json new file mode 100644 index 0000000..ae05e66 --- /dev/null +++ b/.hermes/online-video-player-macos.codex.json @@ -0,0 +1,135 @@ +{ + "schema": "flutter_study.agent_task.v1", + "task_id": "FEAT-20260802-online-video-player-macos", + "objective": "macos_first_online_video_player_module", + "scope": "macos_only", + "agent_role": "code_executor", + "background": { + "repo": "/Users/forest/code/flutter_study", + "branch": "dev", + "host": "macos", + "flutter": "3.44.6", + "macos_deployment_target": "10.15", + "workspace": "pub_workspace, 4 internal packages", + "existing_platform_modules": ["dio_interceptor", "usb_detector"], + "reference_module": "lib/modules/platform/usb_detector", + "learning_package": "packages/flutter_study_learning (exports LearningScaffold, LearningObjectives, ConceptChips, CodeSnippetCard, CommonPitfalls, ExerciseCard)", + "plugin_decision": "media_kit ^1.2.6 + media_kit_video ^2.0.1 + media_kit_libs_video ^1.0.7. NOT flutter official video_player (no first-class macOS support).", + "sample_stream": "https://user-images.githubusercontent.com/28951144/229373695-22f88f13-d18f-4288-9bf1-c3e078d83722.mp4", + "verified_facts": [ + "macos/Runner/DebugProfile.entitlements currently has app-sandbox, cs.allow-jit, network.server, files.user-selected.read-only; MISSING network.client", + "macos/Runner/Release.entitlements currently has app-sandbox, files.user-selected.read-only; MISSING network.client", + "lib/app/app_bootstrap.dart calls WidgetsFlutterBinding.ensureInitialized() first; MediaKit.ensureInitialized() must be added right after it, before runApp", + "ModuleEntry requires: title, path, subtitle, category, difficulty, concepts, estimatedMinutes, status, builder (see lib/module_registry/module_entry.dart)", + "Route table is lib/app/router/app_route_table.dart, _modules list, platform section at bottom" + ] + }, + "module_contract": { + "directory": "lib/modules/platform/online_video_player", + "route": "/online-video-player", + "category": "platform", + "difficulty": "intermediate", + "title": "在线视频播放", + "subtitle": "使用 media_kit 播放在线 HTTP 视频流并操控播放参数", + "concepts": ["media_kit", "libmpv", "HTTP 流", "播放控制", "倍速", "Player 生命周期"], + "estimatedMinutes": 35, + "status": "ready", + "required_files": [ + "module_entry.dart", + "module_root.dart", + "widgets/video_player_controls.dart", + "state/media_kit_player_adapter.dart" + ], + "generated_analysis": "AI_ANALYSIS.md (auto-generated via tool/generate_harness_ai_analysis.sh, DO NOT hand-edit)" + }, + "implementation_steps": [ + { + "step": 1, + "action": "pubspec.yaml", + "detail": "add dependencies: media_kit ^1.2.6, media_kit_video ^2.0.1, media_kit_libs_video ^1.0.7; run flutter pub get" + }, + { + "step": 2, + "action": "lib/app/app_bootstrap.dart", + "detail": "import package:media_kit/media_kit.dart; add MediaKit.ensureInitialized() immediately after WidgetsFlutterBinding.ensureInitialized()" + }, + { + "step": 3, + "action": "macos entitlements", + "detail": "add com.apple.security.network.client=true to BOTH macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements (insert alphabetically, keep existing keys untouched)" + }, + { + "step": 4, + "action": "state/media_kit_player_adapter.dart", + "detail": "wrap Player + VideoController; openAndPlay() opens Media(sampleStreamUrl) with play:true; expose play/pause/togglePlayPause/seek(Duration)/setVolume(double 0-1)/setRate(double); ValueNotifier uiState (idle/loading/playing/paused/error) + ValueNotifier position/duration + ValueNotifier volume/rate; listen player.stream.position/duration/playing; dispose() disposes all notifiers + player; error branch catches open failure and sets uiState=error" + }, + { + "step": 5, + "action": "widgets/video_player_controls.dart", + "detail": "StatelessWidget taking adapter; Row: play/pause IconButton (loading -> CircularProgressIndicator, disabled), position/duration text (mm:ss), DropdownButton rate 0.5x/1.0x/1.5x/2.0x; Slider for seek (max=duration, disabled when zero, clamp), Row: volume icon + Slider + percent text; all via ValueListenableBuilder on adapter notifiers" + }, + { + "step": 6, + "action": "module_root.dart", + "detail": "MyHomePage StatefulWidget; initState -> adapter.openAndPlay(); dispose -> adapter.dispose(); LearningScaffold(title, floatingActionButton refresh -> openAndPlay, interactiveDemo: AspectRatio 16:9 with ValueListenableBuilder on uiState: error->error placeholder, idle->idle placeholder, else Stack[Video(controller), loading overlay]; below: VideoPlayerControls + sample URL text; sections: LearningObjectives/ConceptChips/CodeSnippetCard/CommonPitfalls/ExerciseCard as in reference module)" + }, + { + "step": 7, + "action": "module_entry.dart", + "detail": "OnlineVideoPlayerEntry extends StatelessWidget -> const MyHomePage(title: '在线视频播放')" + }, + { + "step": 8, + "action": "lib/app/router/app_route_table.dart", + "detail": "import online_video_player/module_entry.dart; append ModuleEntry to _modules platform section with ALL required metadata fields" + }, + { + "step": 9, + "action": "tool/generate_agent_indexes.js", + "detail": "modules array append ['platform','online_video_player','/online-video-player','ready',['flutter_study_learning','media_kit','media_kit_video','module_registry']]; categoryMeta.platform children array append 'online_video_player'; depends array append 'media_kit' 'media_kit_video'" + }, + { + "step": 10, + "action": "regenerate", + "detail": "run bash tool/generate_harness_ai_analysis.sh; verify no git diff on generated docs; AI_MODULE_INDEX count 17->18" + }, + { + "step": 11, + "action": "test", + "detail": "test/modules/platform/online_video_player/online_video_player_test.dart: widget test with a FAKE adapter (subclass or mock, no real Player in test env); assert controls render, play/pause toggles icon, error state shows error placeholder; keep tests hermetic (no network)" + }, + { + "step": 12, + "action": "verify", + "detail": "bash tool/quality_gate.sh; flutter build macos --debug (must succeed, proves libmpv native link)" + } + ], + "acceptance_criteria": [ + "flutter analyze 0 errors", + "dart format no drift", + "tool/test_agent_tools.sh 19/19 PASS", + "quality_gate.sh 5/5 PASS", + "AI_MODULE_INDEX.md count = 18", + "route /online-video-player registered and reachable from module home", + "flutter build macos --debug succeeds", + "module_entry.dart has full metadata: category/difficulty/concepts/estimatedMinutes/status/subtitle", + "at least 1 page uses flutter_study_learning teaching components", + "no flutterguard HIGH issues", + "AI_ANALYSIS.md files are generated, not hand-edited" + ], + "forbidden_changes": [ + "no hand-edit of AI_PROJECT_CONTEXT.md / REFACTOR_PLAN.md / AI_MODULE_INDEX.md / packages/*/AI_ANALYSIS.md (regenerate only)", + "no changes to other modules or packages", + "no changes to flutterguard_cli dependency", + "no android/ios platform dirs (out of scope)", + "no commit/push without explicit user approval" + ], + "validation": [ + "bash tool/generate_harness_ai_analysis.sh", + "dart format .", + "flutter analyze", + "bash tool/test_all.sh", + "dart run flutterguard_cli:flutterguard scan . --fail-on high", + "flutter build macos --debug" + ] +} diff --git a/.hermes/plans/2026-08-02_120702-online-video-player-module.md b/.hermes/plans/2026-08-02_120702-online-video-player-module.md new file mode 100644 index 0000000..ae9c46c --- /dev/null +++ b/.hermes/plans/2026-08-02_120702-online-video-player-module.md @@ -0,0 +1,126 @@ +# 在线视频播放模块落地计划 (online_video_player) + +> 日期: 2026-08-02 | 阶段: planning | 状态: awaiting_execution +> 目标: 新增 platform 分类下的在线视频播放学习模块 + +## 1. 需求澄清(重要) + +用户表述「flutter 官方 media_kit」有误,需要澄清: + +| 插件 | 维护方 | 平台支持 | 说明 | +|------|--------|----------|------| +| media_kit 1.2.6 | 社区 (media-kit.dev, verified publisher) | Android/iOS/macOS/Windows/Linux/Web | 基于 libmpv,能力最全:HTTP 流、倍速、音量、seek、轨道、playlist | +| video_player | **Flutter 官方** (flutter/packages) | Android/iOS/Web 官方;macOS/Windows 需第三方适配 | 桌面端是坑,本项目当前宿主就是 macOS/Windows | + +**结论: 选 media_kit。** 理由: +1. 本项目 `AI_PROJECT_CONTEXT.md` 声明 current_hosts=[macos, windows]、next_host=android —— media_kit 桌面+移动全覆盖 +2. 用户需求「直接播放在线 http 托管视频流 + 基本参数操控」正是 media_kit 强项(Open/Play/Pause/Seek/Volume/Rate) +3. video_player 官方不维护桌面端,会立刻卡在 macOS 宿主上 + +## 2. 技术验证 (已完成) + +- media_kit 1.2.6, media_kit_video 2.0.1, media_kit_libs_video 1.0.7 (pub.dev 核实) +- 平台矩阵: Android 5.0+, iOS 9+, macOS 10.9+, Windows 7+ ✅ +- 示例在线视频: `https://user-images.githubusercontent.com/28951144/229373695-22f88f13-d18f-4288-9bf1-c3e078d83722.mp4` (media_kit README 官方示例, GitHub 托管 http 直链) +- macOS 沙箱: 现有 entitlements 只有 `network.server`,**缺少 `network.client`**,在线视频必须补 + +## 3. 模块设计 + +``` +lib/modules/platform/online_video_player/ +├── module_entry.dart # OnlineVideoPlayerEntry → MyHomePage +├── module_root.dart # MyHomePage: LearningScaffold + interactiveDemo +├── widgets/ +│ └── video_player_controls.dart # 自定义控制条(播放/暂停/进度/音量/倍速) +├── state/ +│ └── media_kit_player_adapter.dart # Player 生命周期封装(可测试) +└── AI_ANALYSIS.md # 生成物(勿手改) +``` + +路由: `/online-video-player` | 分类: platform | 难度: intermediate +标题: 「在线视频播放」 | subtitle: 「使用 media_kit 播放在线 HTTP 视频流并操控播放参数」 + +ModuleEntry 元数据: +- concepts: ['media_kit', '视频解码', 'HTTP 流', '播放控制', '倍速', 'Player 生命周期'] +- estimatedMinutes: 35 +- status: ModuleStatus.ready + +教学组件(flutter_study_learning 包): LearningScaffold + LearningObjectives + ConceptChips + CodeSnippetCard + CommonPitfalls + ExerciseCard(全部已有) + +## 4. 落地步骤(执行顺序) + +### Step 1: 依赖接入 +- `pubspec.yaml` 增加: + ```yaml + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + ``` +- `flutter pub get` 验证解析(注意 workspace resolution 下依赖进根 pubspec.lock) + +### Step 2: 宿主初始化 +- `lib/app/app_bootstrap.dart` 在 `WidgetsFlutterBinding.ensureInitialized()` 后加 `MediaKit.ensureInitialized();` + - 位置: bootstrapFlutterStudyApp() 内第一行之后(runApp 之前) + +### Step 3: macOS 网络权限 +- `macos/Runner/DebugProfile.entitlements` 和 `Release.entitlements` 增加: + ```xml + com.apple.security.network.client + + ``` +- 只加 client(出站),不动 server(入站保持现状) + +### Step 4: 模块代码 +- 按 AGENTS.md 新模块规则创建上述 4 个文件 +- 页面结构(仿 usb_detector/module_root.dart): + - interactiveDemo: 16:9 Video(controller) + 控制条(播放/暂停、seek 进度条、音量 Slider、倍速 0.5x~2.0x) + - sections: LearningObjectives / ConceptChips / CodeSnippetCard / CommonPitfalls / ExerciseCard +- 状态封装: `MediaKitPlayerAdapter` 持 Player + VideoController,initState open 在线视频,dispose 释放;暴露 ValueListenable/Stream 给 UI +- 错误分支: open 失败/流不可达时展示错误状态,避免白屏 + +### Step 5: 路由注册 +- `lib/app/router/app_route_table.dart`: + - import `../../modules/platform/online_video_player/module_entry.dart` + - `_modules` 中 platform 段追加 `ModuleEntry(...)` + +### Step 6: 生成源更新(必须,禁止手改生成物) +- `tool/generate_agent_indexes.js`: + - `modules` 数组追加 `['platform', 'online_video_player', '/online-video-player', 'ready', ['flutter_study_learning', 'media_kit', 'media_kit_video', 'module_registry']]` + - `categoryMeta.platform` 的 children 追加 `'online_video_player'` +- 执行 `bash tool/generate_harness_ai_analysis.sh` 重新生成 40+ 契约 + +### Step 7: 测试 +- `test/modules/platform/online_video_player/` widget test: + - 控制条 UI 渲染(用 adapter 抽象,不真实起 Player 避免 CI 无 GPU/网络问题) + - 播放/暂停按钮切换逻辑(mock adapter) + - 失败态展示(open 失败 → 错误提示可见) +- 逻辑代码补充测试(AGENTS.md 要求) + +### Step 8: 质量门禁 +- `bash tool/quality_gate.sh` 全量 5 阶段 +- 特别注意 flutterguard: 避免 HIGH(可变状态暴露等模式,参考现有 5 个 MEDIUM 先例) +- UI 教学页人工验收或截图(AGENTS.md 要求) + +## 5. 风险与对策 + +| 风险 | 影响 | 对策 | +|------|------|------| +| CI (ubuntu) 无 GPU/网络,Player 真实播放失败 | 测试红灯 | widget test 全部走 adapter mock,不 new 真实 Player | +| media_kit_libs_video 体积大 (libmpv) | 构建变慢 | 仅 debug 验证,CI 不 build app 只 analyze+test | +| macOS 沙箱无 network.client | 播放必然失败 | Step 3 补齐 entitlements | +| workspace 下 flutterguard_cli path 依赖 | 已有基线 | 不触碰,保持现状 | +| Android host 尚未创建 | 模块暂无法在 Android 验证 | 模块代码保持平台无关,Android 验证归入 android_host 队列 | + +## 6. 验收标准 + +- [ ] `flutter analyze` 0 error +- [ ] `dart format .` 无漂移 +- [ ] `bash tool/quality_gate.sh` 5/5 通过 +- [ ] 新模块在首页可见,路由 `/online-video-player` 可达 +- [ ] macOS 真机(本机)能播放在线 mp4,控制条可操控 +- [ ] `AI_MODULE_INDEX.md` 更新(生成物自动),count 17→18 +- [ ] 无 HIGH flutterguard 问题 + +## 7. 执行方式 + +按本计划 Step 1→8 顺序执行;每步完成后核对 AGENTS.md 验收规则。 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..92bc26a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.20.2 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 9d9f6e5..e6ae79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,12 @@ ## 前置阅读 执行任何修改前,agent 必须读取: -1. `AI_PROJECT_CONTEXT.md` - 项目整体上下文 -2. `REFACTOR_PLAN.md` - 整改计划与优先级 -3. 目标模块的 `AI_ANALYSIS.md` - 模块结构与修改建议 +1. `AI_ANALYSIS_SCHEMA.json` - agent 文档 schema +2. `AI_PROJECT_CONTEXT.md` - 机器可解析项目契约 +3. `REFACTOR_PLAN.md` - 机器可解析任务队列 +4. 目标模块的 `AI_ANALYSIS.md` - 机器可解析模块契约 + +以上 agent 文档均为 JSON,禁止加入 Markdown、自然语言段落或手工文件清单。 ## 新增模块规则 @@ -16,7 +19,7 @@ | 必需项 | 说明 | |--------|------| | `module_entry.dart` | 导出 `*Entry` Widget,作为模块入口 | -| `AI_ANALYSIS.md` | 模块分析文档:功能、文件结构、数据流、关键类、修改建议 | +| `AI_ANALYSIS.md` | 模块机器契约:route、category、status、entrypoints、owns、depends、analysis_parent、validation | | 路由注册 | 在 `lib/router/app_route_table.dart` 的 `_modules` 中注册 | | 模块元数据 | `ModuleEntry` 必须填写 `category`、`difficulty`、`concepts`、`estimatedMinutes`、`status`、`subtitle` | | 教学页面 | 至少 1 个页面使用外部 `flutter_study_learning` 包中的教学模板组件(`LearningScaffold` 等) | @@ -24,21 +27,27 @@ ## 修改模块规则 1. 修改前先读取该模块的 `AI_ANALYSIS.md` -2. 修改后同步更新 `AI_ANALYSIS.md` 中的文件结构和关键类信息 -3. 如果修改了路由注册,同步更新元数据字段 +2. 修改模块、依赖、路由或层级时,更新 `tool/generate_agent_indexes.js` 中的生成源 +3. 执行 `bash tool/generate_harness_ai_analysis.sh` 重新生成并校验 agent 文档 +4. 如果修改了路由注册,同步更新元数据字段 ## 验收规则 每次代码修改后 **必须** 执行: ```bash -dart format . -flutter analyze -dart run flutterguard_cli:flutterguard scan --path . --fail-on high +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` (无 error) +4. `bash tool/test_all.sh` (全部测试通过) +5. `dart run flutterguard_cli:flutterguard scan . --fail-on high` (无 HIGH 问题) + - `flutter analyze` 必须通过,不允许有 error 级别问题 - `flutterguard scan --fail-on high` 必须通过,不允许引入高优问题 -- 涉及逻辑代码时补充测试(如有测试框架) +- 涉及逻辑代码时补充测试 - 涉及 UI 教学页时进行人工验收或截图说明 ## 禁止事项 diff --git a/AI_ANALYSIS.md b/AI_ANALYSIS.md index 2454def..dc5193f 100644 --- a/AI_ANALYSIS.md +++ b/AI_ANALYSIS.md @@ -10,6 +10,7 @@ }, "entrypoints": [ "lib/main.dart", + "lib/app/app_bootstrap.dart", "lib/app/app.dart", "lib/app/router/app_route_table.dart" ], @@ -21,31 +22,36 @@ "host_integrations" ], "depends": [ - "../gcode_core", - "../flutter_study_learning", - "../file_picker_bridge", - "../flutter_ioc_core", - "../flutterguard/packages/flutterguard_cli" + "packages/gcode_core", + "packages/flutter_study_learning", + "packages/file_picker_bridge", + "packages/flutter_ioc_core", + "git:https://github.com/lizy-coding/flutterguard.git#9f9be84a73dc4b99a956a8529b8c334849566b03" ], "children": [ "lib/AI_ANALYSIS.md", "lib/app/AI_ANALYSIS.md", "lib/module_registry/AI_ANALYSIS.md", "lib/shared/AI_ANALYSIS.md", - "lib/modules/AI_ANALYSIS.md" + "lib/modules/AI_ANALYSIS.md", + "packages/gcode_core/AI_ANALYSIS.md", + "packages/flutter_study_learning/AI_ANALYSIS.md", + "packages/file_picker_bridge/AI_ANALYSIS.md", + "packages/flutter_ioc_core/AI_ANALYSIS.md" ], "contracts": { "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, "validation": [ + "bash tool/generate_harness_ai_analysis.sh", "dart format .", "flutter analyze", - "dart run flutterguard_cli:flutterguard scan --path . --fail-on high" + "dart run flutterguard_cli:flutterguard scan . --fail-on high" ] } diff --git a/AI_ANALYSIS_SCHEMA.json b/AI_ANALYSIS_SCHEMA.json index f78705c..75227e2 100644 --- a/AI_ANALYSIS_SCHEMA.json +++ b/AI_ANALYSIS_SCHEMA.json @@ -1,12 +1,25 @@ { - "schema": "vibecoding.harness.ai_analysis_schema.v1", + "schema": "flutter_study.agent_docs.schema.v2", "syntax": "json_config", "prose": "forbidden", "markdown": "forbidden", + "generated_by": "tool/generate_agent_indexes.js", + "documents": { + "project_context": "AI_PROJECT_CONTEXT.md", + "refactor_plan": "REFACTOR_PLAN.md", + "module_index": "lib/AI_MODULE_INDEX.md", + "analysis_glob": "**/AI_ANALYSIS.md" + }, "levels": { "workspace": [ "AI_ANALYSIS.md" ], + "package_contract": [ + "packages/gcode_core/AI_ANALYSIS.md", + "packages/flutter_study_learning/AI_ANALYSIS.md", + "packages/file_picker_bridge/AI_ANALYSIS.md", + "packages/flutter_ioc_core/AI_ANALYSIS.md" + ], "section": [ "lib/AI_ANALYSIS.md", "lib/app/AI_ANALYSIS.md", @@ -51,8 +64,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness" + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract" }, "module_contract_policy": { "keep_for_module_rule": true, @@ -68,5 +81,21 @@ "long_file_inventory", "natural_language_notes" ] + }, + "package_contract_policy": { + "required_for_workspace_member": true, + "content": [ + "package_type", + "workspace", + "entrypoints", + "owns", + "depends", + "validation", + "test_status" + ], + "avoid": [ + "platform_claims_not_proven_by_manifest", + "natural_language_notes" + ] } } diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index 582b2e8..fe0ab42 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -1,92 +1,175 @@ -# AI 项目上下文 - -> 此文件为 AI 编程助手提供项目整体上下文,修改代码前请先阅读。 - -## 项目概述 - -- **名称**: main_app -- **类型**: Flutter 学习项目集合,单应用多模块架构 -- **支持平台**: macOS, Windows, iOS, Android -- **技术栈**: Flutter 3.x / Dart 3.x - -## 架构模式 - -- **路由**: go_router 统一管理,首页为模块列表,点击后 push 到对应模块 -- **状态管理**: 项目实验多种方案(Provider, Riverpod, Bloc, ChangeNotifier, 自研 IoC) -- **模块组织**: 每个功能模块位于 `lib/modules///` 下,通过 `module_entry.dart` 暴露入口 Widget - -## 关键约定 - -1. **模块入口**: 每个模块必须有 `module_entry.dart`,导出名为 `*Entry` 的 Widget -2. **路由注册**: 新模块需在 `lib/app/router/app_route_table.dart` 的 `_modules` 列表中注册 -3. **依赖**: 所有依赖在根 `pubspec.yaml` 中声明,模块间不共享独立依赖 -4. **命名**: 模块目录使用 snake_case,路由路径使用 kebab-case - -## 目录结构 - -``` -lib/ -├── main.dart # 应用入口,ProviderScope 包裹 -├── app/ -│ ├── app.dart # MaterialApp.router 配置 -│ └── router/ # go_router 路由配置 -│ ├── app_router.dart -│ ├── app_route_table.dart # 路由表 + 模块列表 + 首页 UI -│ └── AI_ANALYSIS.md -├── module_registry/ # 模块元数据定义 -│ ├── module_entry.dart -│ └── module_category.dart -├── shared/ # 共享能力 -│ ├── multi_window/ # 多窗口能力封装 -│ └── platform/ # 平台通道与系统能力封装 -├── modules/ # 学习模块分区 -│ ├── basic/ # 基础机制 -│ ├── async/ # 异步并发 -│ ├── state/ # 状态管理 -│ ├── ui/ # UI 与动效 -│ ├── popup_table/ # 弹窗与列表 -│ └── platform/ # 网络与平台 -``` - -教学模板组件由外部包 `flutter_study_learning` 提供(`LearningScaffold`、`LearningObjectives`、`ConceptChips`、`CodeSnippetCard`、`CommonPitfalls`、`ExerciseCard`、`StateLogView`)。 - -## 模块内部结构(推荐) - -``` -modules/// -├── module_entry.dart # 模块入口 Widget -├── module_routes.dart # 子路由定义(有子路由时才需要) -├── AI_ANALYSIS.md # 模块分析文档 -├── presentation/ # UI 层(pages/ + widgets/) -├── application/ # 应用层(state/ + services/) -├── domain/ # 领域层(models/) -└── data/ # 数据层(api/ + parser/ + mock/) -``` - -简单模块可以保留精简版: -``` -modules/basic/debounce_throttle/ -├── module_entry.dart -├── module_root.dart -├── AI_ANALYSIS.md -└── utils/ -``` - -## 添加新模块步骤 - -1. 在 `lib/modules//` 下创建模块目录 `lib/modules//my_module/` -2. 创建 `module_entry.dart`,导出 `MyModuleEntry` Widget -3. 在 `lib/app/router/app_route_table.dart` 中: - - import 模块入口 - - 在 `_modules` 列表添加 `ModuleEntry` -4. 如需子路由,在模块内定义 `List` 并在 `ModuleEntry.routes` 中传入 - -## 常用命令 - -```bash -flutter pub get # 安装依赖 -flutter run -d macos # 运行 macOS 版本 -flutter analyze # 代码检查 -dart format . # 格式化 -flutter build macos # 构建 macOS 版本 -``` +{ + "schema": "flutter_study.agent_docs.project_context.v1", + "consumer": "coding_agent", + "package": { + "name": "main_app", + "type": "flutter_modular_learning_app", + "sdk": [ + "flutter_3", + "dart_3" + ] + }, + "platform": { + "current_hosts": [ + "macos", + "windows" + ], + "next_host": "android", + "target_hosts": [ + "android", + "ios", + "macos", + "windows" + ] + }, + "entrypoints": { + "process": "lib/main.dart", + "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" + }, + "repository": { + "layout": "pub_workspace", + "workspace_root": ".", + "members": [ + "packages/gcode_core", + "packages/flutter_study_learning", + "packages/file_picker_bridge", + "packages/flutter_ioc_core" + ], + "resolution_status": "active", + "resolution_blocker": "none" + }, + "internal_packages": [ + { + "name": "gcode_core", + "type": "flutter_package", + "path": "packages/gcode_core", + "entrypoint": "lib/gcode_core.dart" + }, + { + "name": "flutter_study_learning", + "type": "flutter_package", + "path": "packages/flutter_study_learning", + "entrypoint": "lib/flutter_study_learning.dart" + }, + { + "name": "file_picker_bridge", + "type": "flutter_bridge_package", + "path": "packages/file_picker_bridge", + "entrypoint": "lib/file_picker_bridge.dart" + }, + { + "name": "flutter_ioc_core", + "type": "dart_package", + "path": "packages/flutter_ioc_core", + "entrypoint": "lib/flutter_ioc_core.dart" + } + ], + "external_tools": [ + { + "package": "flutterguard_cli", + "source": "git", + "url": "https://github.com/lizy-coding/flutterguard.git", + "ref": "9f9be84a73dc4b99a956a8529b8c334849566b03", + "immutable": true, + "lock_status": "active" + } + ], + "layers": [ + { + "id": "app", + "path": "lib/app", + "owns": [ + "host_bootstrap", + "app_shell", + "navigation_policy", + "route_composition" + ], + "may_depend_on": [ + "module_registry", + "shared", + "modules" + ] + }, + { + "id": "module_registry", + "path": "lib/module_registry", + "owns": [ + "module_metadata", + "catalog_operations" + ], + "may_depend_on": [ + "flutter", + "go_router" + ] + }, + { + "id": "shared", + "path": "lib/shared", + "owns": [ + "business_neutral_capabilities", + "platform_boundaries" + ], + "forbidden_dependencies": [ + "app", + "modules" + ] + }, + { + "id": "modules", + "path": "lib/modules/{category}/{module}", + "owns": [ + "learning_ui", + "module_state", + "module_domain", + "module_data" + ], + "forbidden_dependencies": [ + "other_modules" + ] + } + ], + "module_contract": { + "required_files": [ + "module_entry.dart", + "AI_ANALYSIS.md" + ], + "required_registration": "lib/app/router/app_route_table.dart", + "required_metadata": [ + "category", + "difficulty", + "concepts", + "estimatedMinutes", + "status", + "subtitle" + ], + "required_learning_dependency": "flutter_study_learning", + "route_path_style": "kebab_case", + "directory_style": "snake_case" + }, + "platform_rules": { + "router_platform_api": "forbidden", + "module_host_navigation": "forbidden", + "desktop_window_policy": "lib/app/category_navigation.dart", + "platform_capability_contract": "business_neutral_interface" + }, + "change_protocol": { + "pre_read": [ + "AI_PROJECT_CONTEXT.md", + "REFACTOR_PLAN.md", + "{target}/AI_ANALYSIS.md" + ], + "update_source": [ + "tool/generate_agent_indexes.js" + ], + "generate": "bash tool/generate_harness_ai_analysis.sh", + "validate": [ + "bash tool/generate_harness_ai_analysis.sh", + "dart format .", + "flutter analyze", + "dart run flutterguard_cli:flutterguard scan . --fail-on high" + ] + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ee75913 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# 贡献指南 + +## 分支策略 + +- `dev` — 开发主分支 +- `feat/` — 功能分支 +- `fix/` — 修复分支 + +## 提交规范 + +``` +(): + +type: feat, fix, chore, docs, refactor, test +scope: 受影响的模块名或包名 +``` + +示例: +``` +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 + +- [ ] 代码通过 `dart format .`(0 changed) +- [ ] 代码通过 `flutter analyze`(0 errors) +- [ ] 新增逻辑有测试覆盖 +- [ ] Agent 契约已更新(如适用) +- [ ] 质量门禁通过 +- [ ] 教学 UI 变更附截图/说明 + +## 禁止事项 + +- 禁止 `path: ../...` 外部依赖 +- 禁止孤立 demo 页面(无解释无交互) +- 禁止首页出现纯工程目录名 +- 禁止修改生成物而不更新生成源 +- 禁止直接提交到 protected 分支 diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 1f0ba41..25b780f 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -1,158 +1,124 @@ -# 整改计划 - -> 项目重构与代码质量提升计划。优先级从高到低排列。 - -## Phase 1 — 目录结构重构 ✅ - -**目标**: 从扁平 `lib//` 改为 `app/ + module_registry/ + shared/ + modules//` 层级。 - -**已完成**: -- [x] `app.dart` → `app/app.dart` -- [x] `router/` → `app/router/` -- [x] 新建 `module_registry/`,拆分 ModuleEntry 模型和枚举 -- [x] 按 basic/async/state/ui/platform 分类迁移所有 15 个模块 -- [x] 清理目录名(去掉 `_demo`/`_test` 后缀) -- [x] 更新所有 import 路径和文档 - -## Phase 2 — 共享能力归拢 ⏳ - -**目标**: 将已经暴露出跨模块价值的能力从具体学习模块中抽出,先归拢到 `lib/shared/`,等复用边界稳定后再考虑独立 Flutter plugin/package。 - -### 2.1 文件选择能力抽离 - -**背景**: `gcode_visualizer` 已接入 macOS 原生 `NSOpenPanel`,当前实现位于模块内,具备跨模块复用价值,但尚未证明需要独立发布。 - -**目标结构**: - -``` -lib/shared/platform/file_picker/ -├── file_picker_service.dart # 业务无关接口: PickedFile / FilePickerService -├── method_channel_file_picker.dart # MethodChannel 实现 -└── AI_ANALYSIS.md # shared 能力分析文档 -``` - -**Dart API 草案**: - -```dart -class PickedFile { - const PickedFile({ - required this.path, - this.name, - }); - - final String path; - final String? name; +{ + "schema": "flutter_study.agent_docs.refactor_plan.v1", + "objective": "android_readiness_after_architecture_convergence", + "active_phase": "agent_managed", + "completed_milestones": [ + "directory_layers", + "shared_package_extraction", + "module_analysis_coverage", + "app_navigation_boundary", + "host_bootstrap_boundary", + "workspace_package_import", + "agent_takeover_ready" + ], + "dependency_migration": { + "layout": "pub_workspace", + "internal_packages": [ + "packages/gcode_core", + "packages/flutter_study_learning", + "packages/file_picker_bridge", + "packages/flutter_ioc_core" + ], + "workspace_resolution_status": "active", + "workspace_resolution_blocker": "none", + "external_tool": { + "package": "flutterguard_cli", + "source": "git", + "url": "https://github.com/lizy-coding/flutterguard.git", + "ref": "9f9be84a73dc4b99a956a8529b8c334849566b03", + "immutable": true, + "lock_status": "active" + } + }, + "work_queue": [ + { + "id": "module_platform_contract", + "priority": 1, + "status": "pending", + "changes": [ + "ModuleEntry.platform_support", + "ModuleHomePage.availability_state" + ], + "acceptance": [ + "catalog_platform_metadata_complete", + "unsupported_module_state_visible" + ] + }, + { + "id": "platform_plugin_audit", + "priority": 2, + "status": "pending", + "targets": [ + "desktop_multi_window", + "file_picker_bridge", + "usb_serial", + "device_info_plus" + ], + "acceptance": [ + "android_support_matrix", + "unsupported_fallbacks" + ] + }, + { + "id": "usb_platform_boundary", + "priority": 3, + "status": "pending", + "targets": [ + "lib/modules/platform/usb_detector" + ], + "acceptance": [ + "no_windows_hardcode", + "android_system_info", + "error_branch_test" + ] + }, + { + "id": "mobile_layout_baseline", + "priority": 4, + "status": "pending", + "viewport_width_dp": 360, + "targets": [ + "module_home", + "category_home", + "ready_modules", + "recommended_modules" + ], + "acceptance": [ + "no_overflow", + "safe_area", + "keyboard_avoidance", + "touch_targets" + ] + }, + { + "id": "android_host", + "priority": 5, + "status": "blocked_by_dependencies", + "depends_on": [ + "module_platform_contract", + "platform_plugin_audit", + "mobile_layout_baseline" + ], + "acceptance": [ + "android_directory", + "manifest_capabilities", + "debug_apk", + "emulator_smoke" + ] + } + ], + "quality_gate": [ + "node tool/validate_agent_docs.js", + "dart format .", + "flutter analyze:no_error", + "flutterguard:no_high", + "logic_change:targeted_test", + "teaching_ui_change:visual_evidence" + ], + "deferred_queue": [ + "popup_widgets_decomposition", + "widget_test_coverage", + "flutterguard_med_reduction", + "recommended_module_visual_evidence" + ] } - -abstract class FilePickerService { - Future pickFile({ - List allowedExtensions = const [], - String? title, - String? message, - }); -} -``` - -**macOS 原生侧目标**: -- Channel 从 `flutter_study/gcode_file_picker` 调整为 `file_picker_bridge/file_picker` -- `pickFile` 支持 `allowedExtensions`、`title`、`message` -- 保留 `com.apple.security.files.user-selected.read-only` -- G-code 模块只传 G-code 扩展名,不再持有平台通道细节 - -**实施步骤**: -- [x] 新增 `lib/shared/platform/file_picker/` 共享能力目录 -- [x] 将 `GcodeFilePicker` 改造成通用 `FilePickerService` -- [x] 调整 macOS `AppDelegate.swift` MethodChannel 名称和参数协议 -- [x] `gcode_visualizer` 只依赖 shared file picker 接口 -- [x] 删除模块内 `services/gcode_file_picker.dart` -- [x] 补充 shared 能力 `AI_ANALYSIS.md` -- [x] 为 MethodChannel service 增加可 mock 的单元测试 - -**暂不独立成插件包的原因**: -- 当前只有 macOS 实现,API 仍小 -- 只有 `gcode_visualizer` 一个模块实际使用 -- 独立 plugin 会引入额外的 package、registrant、Pods/Gradle 维护成本 - -**升级为独立 plugin/package 的触发条件**: -- 2 个以上模块稳定复用 -- 需要 Windows/iOS/Android 文件选择实现 -- 需要被其它工程复用 -- 需要独立版本、测试、发布节奏 - -### 2.2 shared 能力治理 - -- [x] 教学模板组件已从 `lib/shared/learning/` 抽出为外部 `flutter_study_learning` 包 -- [x] `file_picker` 已从 `lib/shared/platform/` 抽出为外部 `file_picker_bridge` 包 -- [x] 为 `lib/shared/platform/` 补充平台能力说明 `AI_ANALYSIS.md` -- [ ] 共享能力必须提供业务无关接口,模块只能传入业务参数 -- [ ] 共享能力新增后必须至少被 1 个模块接入验证 -- [ ] 若 shared 能力 3 个月内仍只有 1 个模块使用,保留在 shared,但不升级为独立插件 - -## Phase 3 — 模块内部规范化 ⏳ - -**目标**: 逐步统一模块内部分层(presentation/application/domain/data)。 - -- [ ] `popup_widgets` — 拆分 895 行 `module_root.dart` 为独立页面 -- [ ] `debounce_throttle` — 迁移为 `module_entry + module_root + utils` 精简模式 -- [ ] `status_management` — 保持 app/features/shared 分层,添加测试 -- [x] 所有模块补齐 `AI_ANALYSIS.md`(如有缺失) -- [x] `gcode_visualizer` — 文件选择器抽到 `shared/platform/file_picker` 后更新模块分析文档 - -## Phase 4 — 测试与质量 🔲 - -**目标**: 提升测试覆盖率和代码质量。 - -- [ ] 为无测试模块补充基础 Widget 测试 -- [ ] 引入 lint 规则增强(如 `prefer_const_constructors`) -- [ ] `scroll_table` 和 `usb_detector` — 从 `ModuleStatus.pending` 提升到 `ready` -- [ ] 建立 shared 能力测试样例: MethodChannel mock、错误分支、取消选择分支 -- [ ] FlutterGuard MED 项分批治理,优先处理 shared 与推荐模块 - -## Phase 5 — 教学体验 🔲 - -**目标**: 更多模块使用教学模板(`LearningScaffold`)。 - -- [x] `adsorption_line` — 改造为教学页面 -- [x] `stream_subscription` — 改造为教学页面 -- [x] `download_animation` — 改造为教学页面 - -## Phase 6 — 项目整体归拢 🔲 - -**目标**: 从“模块集合”收敛为“可维护的学习平台”,统一入口、共享能力、模块质量和验收标准。 - -### 6.1 目录归拢 - -- [ ] `app/`: 只放应用壳、路由、主题入口,不放模块业务 -- [ ] `module_registry/`: 只放模块元数据模型和枚举 -- [ ] `shared/learning/`: 只放教学模板组件 -- [ ] `shared/platform/`: 只放平台通道、系统能力、设备能力等业务无关服务 -- [ ] `modules///`: 只放模块内业务、页面、状态、解析器和模块文档 - -### 6.2 模块质量分级 - -- [ ] `ModuleStatus.pending`: 有明显结构、文档、体验或质量缺口 -- [ ] `ModuleStatus.ready`: 可独立学习,元数据完整,分析文档完整,基础验收通过 -- [ ] `ModuleStatus.recommended`: 使用教学模板,交互完整,有测试覆盖,FlutterGuard 无新增 high - -### 6.3 每轮重构验收 - -每次涉及代码调整后继续执行: - -```bash -dart format . -flutter analyze -dart run flutterguard_cli:flutterguard scan --path . --fail-on high -``` - -按变更类型补充: -- 逻辑/解析器: 跑对应 `flutter test` -- macOS 原生改动: 跑 `flutter build macos` -- UI 教学页: 截图或人工验收说明 - -### 6.4 近期优先级 - -1. 修正并稳定 `AI_ANALYSIS.md` 层级生成/维护规则,避免根文档再次退化为全量文件清单 -2. `popup_widgets` 拆分 900+ 行 `module_root.dart`,降低 FlutterGuard MED/LOW 噪音 -3. 为已使用 `LearningScaffold` 的 ready 模块补齐 Widget smoke test,满足后续推荐条件 -4. 建立 shared 能力测试模板,覆盖多窗口、平台桥接、取消/错误分支 -5. 对推荐模块逐步补齐截图或人工验收记录 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..2470087 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,93 @@ +# 开发指南 + +## 环境要求 + +| 组件 | 版本 | 说明 | +|------|------|------| +| Flutter | 3.44.6 | 见 `.fvmrc` | +| Dart | 3.12.2 | 随 Flutter | +| Node.js | 20.20.2 | 见 `.nvmrc`(供 Agent 文档生成器) | +| Xcode | 26+ | 仅 macOS/iOS 构建 | +| Android SDK | 36+ | 仅 Android 构建 | + +## 快速开始 + +```bash +# 1. 自举 +bash tool/bootstrap.sh + +# 2. 运行应用 +flutter run + +# 3. 质量门禁(提交前必跑) +bash tool/quality_gate.sh +``` + +## 项目结构 + +``` +lib/ +├── main.dart # 入口 +├── app/ # 应用壳 + 路由 +│ ├── app_bootstrap.dart # 宿主引导 +│ ├── app.dart # MaterialApp.router +│ └── router/ # go_router 路由表 +├── module_registry/ # 模块元数据 +│ ├── module_entry.dart # ModuleEntry 数据类 +│ └── module_category.dart # 枚举定义 +├── shared/ # 业务无关能力 +│ ├── multi_window/ # 桌面多窗口 +│ └── platform/ # 平台边界 +└── modules/ # 学习模块 + ├── basic/ # 基础机制 (3) + ├── async/ # 异步并发 (3) + ├── state/ # 状态管理 (2) + ├── ui/ # UI 与动效 (3) + ├── popup_table/ # 弹窗与列表 (4) + └── platform/ # 网络与平台 (2) + +packages/ +├── gcode_core/ # G-code 解析 +├── flutter_study_learning/ # 教学模板组件 +├── file_picker_bridge/ # 文件选择桥接 +└── flutter_ioc_core/ # IoC 容器 + +tool/ +├── bootstrap.sh # 环境自举 +├── quality_gate.sh # 全量质量门禁 +├── test_all.sh # 全量测试 +├── check_environment.sh # 环境检查 +├── generate_agent_indexes.js # Agent 文档生成器 +├── validate_agent_docs.js # Agent 文档校验器 +└── generate_harness_ai_analysis.sh # 生成+校验入口 +``` + +## 常用命令 + +| 命令 | 说明 | +|------|------| +| `bash tool/bootstrap.sh` | 环境自举 | +| `bash tool/quality_gate.sh` | 全量质量门禁(提交前必跑) | +| `bash tool/test_all.sh` | 全量测试 | +| `flutter run` | 启动应用 | +| `flutter analyze` | 静态分析 | +| `dart format .` | 格式化 | +| `dart run flutterguard_cli:flutterguard scan . --fail-on high` | 安全扫描 | + +## 故障排查 + +### 依赖解析失败 +```bash +flutter clean +flutter pub get +``` + +### Agent 文档校验失败 +```bash +bash tool/generate_harness_ai_analysis.sh +``` + +### Git hooks 未生效 +```bash +git config core.hooksPath .githooks +``` diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..bd922c2 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,49 @@ +# 测试指南 + +## 测试分层 + +| 层级 | 目录 | 工具 | 覆盖范围 | +|------|------|------|------| +| 单元测试 | `test/` | flutter_test | 逻辑、模型、服务 | +| Widget 测试 | `test/` | flutter_test | UI 组件行为 | +| 集成测试 | `integration_test/` | integration_test | 端到端用户流程 | +| 包测试 | `packages/*/test/` | flutter_test / dart test | 各包独立测试 | + +## 执行测试 + +```bash +# 全量测试(主应用 + workspace packages) +bash tool/test_all.sh + +# 仅主应用 +flutter test + +# 指定文件 +flutter test test/gcode_visualizer/gcode_visualizer_page_test.dart + +# 纯 Dart 包 +cd packages/flutter_ioc_core && dart test +``` + +## 何时必须补测试 + +| 变更类型 | 要求 | +|------|------| +| 新增逻辑代码 | 补充单元测试 | +| 修改公共 API | 更新已有测试 | +| 新增模块 | 至少补入口 smoke test | +| 修复 bug | 补回归测试 | +| 平台特定功能 | 补平台分支测试 | + +## 新模块验收 + +新模块必须通过: +1. 模块入口 smoke test(widget 可渲染) +2. 教学模板验证(使用了 flutter_study_learning 组件) +3. 平台支持声明(ModuleEntry.platform_support) + +## 测试环境 + +- 测试运行不依赖真实网络(使用 mock) +- 平台特定测试需在目标平台执行 +- Golden 测试仅用于稳定组件 diff --git a/docs/adr/0001-repository-layout.md b/docs/adr/0001-repository-layout.md new file mode 100644 index 0000000..bbdae5c --- /dev/null +++ b/docs/adr/0001-repository-layout.md @@ -0,0 +1,29 @@ +# ADR 0001: 单仓布局 (Pub Workspace) + +| 属性 | 值 | +|------|-----| +| 状态 | accepted | +| 日期 | 2026-07-25 | +| 决策者 | forest | + +## 上下文 + +项目依赖 4 个共享包(gcode_core, flutter_study_learning, file_picker_bridge, flutter_ioc_core)和 1 个外部工具(flutterguard_cli)。这些包原本以 `../` 相对路径引用,依赖开发机目录布局。 + +## 决策 + +1. 内部共享包迁移至 `packages/` 目录,以 Dart Pub Workspace 管理 +2. flutterguard_cli 保持为外部 Git 依赖,以不可变 tag/commit 固定 +3. 禁止 `path: ../...` 相对路径依赖 + +## 理由 + +- Agent 在单一 clone 中即可获取源码、运行测试、提交原子变更 +- 不依赖开发机目录布局 +- Dart Pub Workspace 提供统一的依赖解析 + +## 后果 + +- 4 个共享包从仓库外迁入,git history 分离 +- flutterguard_cli 更新需显式修改 Git ref +- 新开发者无需额外 clone 其他仓库 diff --git a/docs/adr/0002-agent-contract-source-of-truth.md b/docs/adr/0002-agent-contract-source-of-truth.md new file mode 100644 index 0000000..55a2764 --- /dev/null +++ b/docs/adr/0002-agent-contract-source-of-truth.md @@ -0,0 +1,31 @@ +# ADR 0002: Agent 契约生成源 + +| 属性 | 值 | +|------|-----| +| 状态 | accepted | +| 日期 | 2026-07-25 | +| 决策者 | forest | + +## 上下文 + +项目使用机器可解析的 JSON 契约(AI_ANALYSIS.md 系列)和 human-facing 文档(README.md, CONTRIBUTING.md 等)。需要明确哪些文档生成、哪些手写,避免双源漂移。 + +## 决策 + +1. Agent 机器契约由 `tool/generate_agent_indexes.js` 生成 +2. 生成物包括:AI_MODULE_INDEX.md, AI_PROJECT_CONTEXT.md, REFACTOR_PLAN.md 中的部分字段 +3. 路由注册表(app_route_table.dart)为生成源之一 +4. 模块级 AI_ANALYSIS.md 为手写 + 生成混合(路由/状态来自生成器,owns/depends 手写) +5. 人类文档(README.md, docs/*)为纯手写 + +## 理由 + +- 模块清单、路由和状态不应靠手工重复维护 +- 生成 + diff 检测可防止漂移 +- 手写部分保留架构意图(owns/depends 不可从代码自动推导) + +## 后果 + +- 修改路由必须更新生成源(route_table.dart) +- 新增模块必须在生成源中注册 +- CI 检测生成物漂移会自动失败 diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..6493274 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,19 @@ +# Architecture Decision Records + +记录不可从代码直接推导的架构决策原因。 + +## ADR 列表 + +| 编号 | 标题 | 状态 | +|------|------|------| +| 0001 | 单仓布局 (Pub Workspace) | accepted | +| 0002 | Agent 契约生成源 | accepted | +| 0003 | 平台能力边界 | proposed | +| 0004 | 质量门禁策略 | proposed | + +## 状态定义 + +- `proposed` — 提议阶段,待讨论 +- `accepted` — 已接受,当前生效 +- `superseded` — 被后续 ADR 取代 +- `deprecated` — 已废弃 diff --git a/docs/agent/CHANGE_REPORT_SCHEMA.json b/docs/agent/CHANGE_REPORT_SCHEMA.json new file mode 100644 index 0000000..19f541c --- /dev/null +++ b/docs/agent/CHANGE_REPORT_SCHEMA.json @@ -0,0 +1,25 @@ +{ + "schema": "flutter_study.agent_docs.change_report_schema.v1", + "consumer": "coding_agent", + "description": "Agent 变更报告格式 — 每次任务完成后的结构化输出", + "fields": { + "task_id": {"type": "string", "required": true, "description": "关联任务 ID"}, + "changed_files": {"type": "array", "items": "string", "required": true, "description": "修改的文件路径列表"}, + "tests_added": {"type": "array", "items": "string", "required": false, "description": "新增/修改的测试文件"}, + "commands": {"type": "array", "items": "string", "required": true, "description": "执行的验证命令"}, + "results": { + "type": "object", + "fields": { + "analyze": {"type": "string", "enum": ["pass", "fail", "partial", "skipped"]}, + "format": {"type": "string", "enum": ["pass", "fail", "skipped"]}, + "test": {"type": "string", "enum": ["pass", "fail", "partial", "skipped"]}, + "flutterguard": {"type": "string", "enum": ["pass", "fail", "skipped"]}, + "agent_docs": {"type": "string", "enum": ["pass", "fail", "skipped"]} + } + }, + "generated_artifacts": {"type": "array", "items": "string", "description": "生成的产物路径"}, + "known_risks": {"type": "array", "items": "string", "description": "已知剩余风险"}, + "manual_verification": {"type": "array", "items": "string", "description": "需要人工验证的项目"}, + "followups": {"type": "array", "items": "string", "description": "后续跟进事项"} + } +} diff --git a/docs/agent/COMMANDS.json b/docs/agent/COMMANDS.json new file mode 100644 index 0000000..5244cf5 --- /dev/null +++ b/docs/agent/COMMANDS.json @@ -0,0 +1,71 @@ +{ + "schema": "flutter_study.agent_docs.commands.v1", + "consumer": "coding_agent", + "description": "项目命令清单 — 所有可执行命令的标准化描述", + "commands": { + "bootstrap": { + "cmd": "bash tool/bootstrap.sh", + "description": "环境自举:校验环境 + 获取依赖 + 启用 hooks + smoke check", + "modifies_files": false, + "timeout_seconds": 180, + "artifacts": [], + "success_condition": "exit_code=0" + }, + "quality_gate": { + "cmd": "bash tool/quality_gate.sh", + "description": "全量质量门禁:文档校验 → 格式 → 分析 → 测试 → FlutterGuard", + "modifies_files": false, + "timeout_seconds": 300, + "artifacts": [], + "success_condition": "exit_code=0" + }, + "check_environment": { + "cmd": "bash tool/check_environment.sh", + "description": "环境组件版本检查", + "modifies_files": false, + "timeout_seconds": 30, + "artifacts": [], + "success_condition": "exit_code=0" + }, + "test_all": { + "cmd": "bash tool/test_all.sh", + "description": "遍历主应用和 workspace packages 执行测试", + "modifies_files": false, + "timeout_seconds": 300, + "artifacts": [], + "success_condition": "exit_code=0" + }, + "generate_agent_docs": { + "cmd": "bash tool/generate_harness_ai_analysis.sh", + "description": "生成 Agent 索引 + 校验文档一致性", + "modifies_files": true, + "timeout_seconds": 30, + "artifacts": ["lib/AI_MODULE_INDEX.md", "AI_PROJECT_CONTEXT.md", "REFACTOR_PLAN.md"], + "success_condition": "exit_code=0 && agent_docs_valid >= 40" + }, + "format": { + "cmd": "dart format .", + "description": "格式化所有 Dart 文件", + "modifies_files": true, + "timeout_seconds": 30, + "artifacts": [], + "success_condition": "exit_code=0 && 0 files changed" + }, + "analyze": { + "cmd": "flutter analyze", + "description": "Flutter 静态分析", + "modifies_files": false, + "timeout_seconds": 120, + "artifacts": [], + "success_condition": "exit_code=0 && 0 errors" + }, + "flutterguard": { + "cmd": "dart run flutterguard_cli:flutterguard scan . --fail-on high", + "description": "FlutterGuard 安全扫描", + "modifies_files": false, + "timeout_seconds": 60, + "artifacts": [], + "success_condition": "exit_code=0 && 0 high issues" + } + } +} diff --git a/docs/agent/TASK_SCHEMA.json b/docs/agent/TASK_SCHEMA.json new file mode 100644 index 0000000..ecd5330 --- /dev/null +++ b/docs/agent/TASK_SCHEMA.json @@ -0,0 +1,26 @@ +{ + "schema": "flutter_study.agent_docs.task_schema.v1", + "consumer": "coding_agent", + "description": "Agent 任务输入格式 — 每个任务声明边界、依赖、验收条件和风险等级", + "fields": { + "id": {"type": "string", "required": true, "description": "唯一任务标识符"}, + "goal": {"type": "string", "required": true, "description": "任务目标(一句话)"}, + "background": {"type": "string", "required": false, "description": "背景上下文"}, + "scope": { + "type": "object", + "required": true, + "description": "允许修改的文件范围", + "fields": { + "allow": {"type": "array", "items": "string", "description": "允许修改的文件/目录 glob"}, + "deny": {"type": "array", "items": "string", "description": "禁止修改的文件/目录 glob"} + } + }, + "pre_read": {"type": "array", "items": "string", "description": "执行前必须读取的文件"}, + "dependencies": {"type": "array", "items": "string", "description": "前置依赖任务 ID 列表"}, + "acceptance": {"type": "array", "items": "string", "description": "验收条件(可执行命令或可验证声明)"}, + "validation": {"type": "array", "items": "string", "description": "验证命令列表"}, + "risk": {"type": "string", "enum": ["low", "medium", "high"], "description": "风险等级"}, + "manual_review": {"type": "boolean", "default": false, "description": "是否需要人工审查"}, + "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "blocked", "cancelled"], "default": "pending"} + } +} diff --git a/flutterguard.yaml b/flutterguard.yaml index 4077529..d887ae9 100644 --- a/flutterguard.yaml +++ b/flutterguard.yaml @@ -34,9 +34,4 @@ boundaries: - lib/modules/** - lib/app/** - # shared 层必须纯净,不能反向依赖模块或 app - - name: shared_purity - from: lib/shared/** - forbidden: - - lib/modules/** - - lib/app/** + diff --git a/lib/AI_ANALYSIS.md b/lib/AI_ANALYSIS.md index 6c73b1b..d0356bd 100644 --- a/lib/AI_ANALYSIS.md +++ b/lib/AI_ANALYSIS.md @@ -10,6 +10,7 @@ }, "entrypoints": [ "main.dart", + "app/app_bootstrap.dart", "app/app.dart", "app/router/app_route_table.dart" ], @@ -34,8 +35,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/AI_MODULE_INDEX.md b/lib/AI_MODULE_INDEX.md index ce549eb..41827ca 100644 --- a/lib/AI_MODULE_INDEX.md +++ b/lib/AI_MODULE_INDEX.md @@ -1,58 +1,245 @@ -# AI 模块索引 - -> 此文件描述 lib/ 下所有模块的结构,AI 修改模块代码前请查阅对应模块的 AI_ANALYSIS.md。 - -## 模块列表 - -| 模块 | 路径 | 路由路径 | 状态管理 | 复杂度 | AI 分析文件 | -|------|------|---------|---------|--------|------------| -| adsorption_line | `modules/ui/adsorption_line` | /adsorption-line | ChangeNotifier + Provider | 高 | `modules/ui/adsorption_line/AI_ANALYSIS.md` | -| debounce_throttle | `modules/basic/debounce_throttle` | /debounce-throttle | StatefulWidget | 低 | `modules/basic/debounce_throttle/AI_ANALYSIS.md` | -| download_animation | `modules/ui/download_animation` | /download-animation | StatefulWidget | 中 | `modules/ui/download_animation/AI_ANALYSIS.md` | -| flutter_ioc | `modules/state/flutter_ioc` | /flutter-ioc | 自研 IoC + Provider | 中 | `modules/state/flutter_ioc/AI_ANALYSIS.md` | -| gcode_visualizer | `modules/ui/gcode_visualizer` | /gcode-visualizer | ChangeNotifier + AnimationController | 高 | `modules/ui/gcode_visualizer/AI_ANALYSIS.md` | -| dio_interceptor | `modules/platform/dio_interceptor` | /dio-interceptor | 无(Dio 拦截器) | 中 | `modules/platform/dio_interceptor/AI_ANALYSIS.md` | -| isolate_task_manager | `modules/async/isolate_task_manager` | /isolate-stream | StatefulWidget | 中 | `modules/async/isolate_task_manager/AI_ANALYSIS.md` | -| isolate_basic | `modules/async/isolate_basic` | /isolate-basic | StatefulWidget | 低 | `modules/async/isolate_basic/AI_ANALYSIS.md` | -| microtask | `modules/basic/microtask` | /microtask | StatefulWidget | 低 | `modules/basic/microtask/AI_ANALYSIS.md` | -| popup_widgets | `modules/popup_table/popup_widgets` | /popup-widgets | StatefulWidget | 高 | `modules/popup_table/popup_widgets/AI_ANALYSIS.md` | -| popup_list_interaction | `modules/popup_table/popup_list_interaction` | /popup-list-interaction | StatefulWidget | 低 | `modules/popup_table/popup_list_interaction/AI_ANALYSIS.md` | -| scroll_table | `modules/popup_table/scroll_table` | /scroll-table | 无 | 低 | `modules/popup_table/scroll_table/AI_ANALYSIS.md` | -| overlay_follow_compare | `modules/popup_table/overlay_follow_compare` | /overlay-compare | StatefulWidget | 中 | `modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md` | -| status_management | `modules/state/status_management` | /status-management | Provider/Riverpod/Bloc | 高 | `modules/state/status_management/AI_ANALYSIS.md` | -| stream_subscription | `modules/async/stream_subscription` | /stream-subscription | StreamController | 中 | `modules/async/stream_subscription/AI_ANALYSIS.md` | -| tree_state | `modules/basic/tree_state` | /tree-state | StatefulWidget | 低 | `modules/basic/tree_state/AI_ANALYSIS.md` | -| usb_detector | `modules/platform/usb_detector` | /usb-detector | StreamController | 中 | `modules/platform/usb_detector/AI_ANALYSIS.md` | - -## 模块模式分类 - -### 模式 A: 简单入口(module_entry -> module_root) -- debounce_throttle -- download_animation -- flutter_ioc -- isolate_basic -- isolate_task_manager -- overlay_follow_compare -- popup_list_interaction -- popup_widgets -- scroll_table -- usb_detector - -### 模式 B: 页面路由型(module_entry -> module_routes -> pages) -- tree_state -- microtask -- stream_subscription -- dio_interceptor -- status_management - -### 模式 C: 功能分区型(module_entry 直接装配 pages/state/widgets/services) -- adsorption_line(models/state/services/widgets) -- gcode_visualizer(models/parser/services/state/widgets/pages) - -## 层级维护规则 - -- 根级 `AI_ANALYSIS.md` 只记录工作区层级、模块总数、外部包边界和下一步队列。 -- `lib/app/**/AI_ANALYSIS.md` 只记录应用壳和路由聚合,不展开模块内部细节。 -- `lib/shared/**/AI_ANALYSIS.md` 只记录业务无关共享能力、平台边界和可复用 API。 -- `lib/modules/**/AI_ANALYSIS.md` 只记录单模块结构、数据流、关键类、教学组件和变更备注。 -- 新增或迁移模块时,同步更新本索引、模块自身 `AI_ANALYSIS.md` 和 `lib/app/router/app_route_table.dart`。 +{ + "schema": "flutter_study.agent_docs.module_index.v1", + "registry": "lib/app/router/app_route_table.dart", + "count": 18, + "modules": [ + { + "id": "tree_state", + "category": "basic", + "path": "lib/modules/basic/tree_state", + "route": "/tree-state", + "status": "recommended", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/basic/tree_state/AI_ANALYSIS.md" + }, + { + "id": "microtask", + "category": "basic", + "path": "lib/modules/basic/microtask", + "route": "/microtask", + "status": "recommended", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/basic/microtask/AI_ANALYSIS.md" + }, + { + "id": "debounce_throttle", + "category": "basic", + "path": "lib/modules/basic/debounce_throttle", + "route": "/debounce-throttle", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry" + ], + "analysis": "lib/modules/basic/debounce_throttle/AI_ANALYSIS.md" + }, + { + "id": "stream_subscription", + "category": "async", + "path": "lib/modules/async/stream_subscription", + "route": "/stream-subscription", + "status": "recommended", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/async/stream_subscription/AI_ANALYSIS.md" + }, + { + "id": "isolate_basic", + "category": "async", + "path": "lib/modules/async/isolate_basic", + "route": "/isolate-basic", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/async/isolate_basic/AI_ANALYSIS.md" + }, + { + "id": "isolate_task_manager", + "category": "async", + "path": "lib/modules/async/isolate_task_manager", + "route": "/isolate-stream", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry" + ], + "analysis": "lib/modules/async/isolate_task_manager/AI_ANALYSIS.md" + }, + { + "id": "status_management", + "category": "state", + "path": "lib/modules/state/status_management", + "route": "/status-management", + "status": "recommended", + "depends": [ + "flutter_study_learning", + "provider", + "flutter_riverpod", + "flutter_bloc", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/state/status_management/AI_ANALYSIS.md" + }, + { + "id": "flutter_ioc", + "category": "state", + "path": "lib/modules/state/flutter_ioc", + "route": "/flutter-ioc", + "status": "ready", + "depends": [ + "flutter_study_learning", + "flutter_ioc_core", + "provider", + "module_registry" + ], + "analysis": "lib/modules/state/flutter_ioc/AI_ANALYSIS.md" + }, + { + "id": "gcode_visualizer", + "category": "ui", + "path": "lib/modules/ui/gcode_visualizer", + "route": "/gcode-visualizer", + "status": "ready", + "depends": [ + "flutter_study_learning", + "gcode_core", + "file_picker_bridge", + "module_registry" + ], + "analysis": "lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md" + }, + { + "id": "adsorption_line", + "category": "ui", + "path": "lib/modules/ui/adsorption_line", + "route": "/adsorption-line", + "status": "ready", + "depends": [ + "flutter_study_learning", + "provider", + "module_registry" + ], + "analysis": "lib/modules/ui/adsorption_line/AI_ANALYSIS.md" + }, + { + "id": "download_animation", + "category": "ui", + "path": "lib/modules/ui/download_animation", + "route": "/download-animation", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/ui/download_animation/AI_ANALYSIS.md" + }, + { + "id": "popup_widgets", + "category": "popup_table", + "path": "lib/modules/popup_table/popup_widgets", + "route": "/popup-widgets", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry" + ], + "analysis": "lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md" + }, + { + "id": "popup_list_interaction", + "category": "popup_table", + "path": "lib/modules/popup_table/popup_list_interaction", + "route": "/popup-list-interaction", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md" + }, + { + "id": "scroll_table", + "category": "popup_table", + "path": "lib/modules/popup_table/scroll_table", + "route": "/scroll-table", + "status": "ready", + "depends": [ + "flutter_study_learning", + "two_dimensional_scrollables", + "module_registry" + ], + "analysis": "lib/modules/popup_table/scroll_table/AI_ANALYSIS.md" + }, + { + "id": "overlay_follow_compare", + "category": "popup_table", + "path": "lib/modules/popup_table/overlay_follow_compare", + "route": "/overlay-compare", + "status": "ready", + "depends": [ + "flutter_study_learning", + "module_registry" + ], + "analysis": "lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md" + }, + { + "id": "dio_interceptor", + "category": "platform", + "path": "lib/modules/platform/dio_interceptor", + "route": "/dio-interceptor", + "status": "ready", + "depends": [ + "flutter_study_learning", + "dio", + "module_registry", + "go_router" + ], + "analysis": "lib/modules/platform/dio_interceptor/AI_ANALYSIS.md" + }, + { + "id": "usb_detector", + "category": "platform", + "path": "lib/modules/platform/usb_detector", + "route": "/usb-detector", + "status": "ready", + "depends": [ + "flutter_study_learning", + "usb_serial", + "device_info_plus", + "module_registry" + ], + "analysis": "lib/modules/platform/usb_detector/AI_ANALYSIS.md" + }, + { + "id": "online_video_player", + "category": "platform", + "path": "lib/modules/platform/online_video_player", + "route": "/online-video-player", + "status": "ready", + "depends": [ + "flutter_study_learning", + "media_kit", + "media_kit_video", + "module_registry" + ], + "analysis": "lib/modules/platform/online_video_player/AI_ANALYSIS.md" + } + ] +} diff --git a/lib/app/AI_ANALYSIS.md b/lib/app/AI_ANALYSIS.md index fc31e42..8ca5e14 100644 --- a/lib/app/AI_ANALYSIS.md +++ b/lib/app/AI_ANALYSIS.md @@ -10,16 +10,25 @@ }, "entrypoints": [ "app.dart", + "app_bootstrap.dart", + "module_home_page.dart", + "category_navigation.dart", + "category_window_app.dart", "router/app_router.dart", "router/app_route_table.dart" ], "owns": [ + "host_bootstrap", "material_app_router", - "router" + "router", + "module_home", + "adaptive_category_navigation", + "desktop_category_window_shell" ], "depends": [ "go_router", "module_registry", + "shared/multi_window", "modules" ], "children": [ @@ -29,8 +38,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart new file mode 100644 index 0000000..3a322c0 --- /dev/null +++ b/lib/app/app_bootstrap.dart @@ -0,0 +1,27 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../shared/multi_window/multi_window_manager.dart'; +import 'app.dart'; +import 'category_window_app.dart'; + +/// Resolves the host-specific application shell before mounting Flutter. +Future bootstrapFlutterStudyApp() async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + + Widget root = const App(); + if (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!); + } + } + + runApp(ProviderScope(child: root)); +} diff --git a/lib/app/category_navigation.dart b/lib/app/category_navigation.dart new file mode 100644 index 0000000..6c8272f --- /dev/null +++ b/lib/app/category_navigation.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.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'; + +/// Selects the platform-appropriate way to open a module category. +/// +/// Desktop hosts may create a separate window. Mobile and other hosts keep the +/// same content inside the current navigation stack. +class CategoryNavigation { + const CategoryNavigation._(); + + static bool get opensInNewWindow => MultiWindowManager.isSupported; + + static Future open( + BuildContext context, { + required ModuleCategory category, + required List modules, + }) async { + if (opensInNewWindow) { + 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), + ), + ); + } +} diff --git a/lib/app/category_window_app.dart b/lib/app/category_window_app.dart new file mode 100644 index 0000000..8007f05 --- /dev/null +++ b/lib/app/category_window_app.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +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_home_page.dart'; +import 'router/app_route_table.dart'; + +class CategoryWindowApp extends StatelessWidget { + const CategoryWindowApp({super.key, required this.category}); + + final ModuleCategory category; + + static GoRouter createRouter(ModuleCategory category) { + final modules = filterModulesByCategory(AppRouteTable.modules, category); + final childRoutes = buildCategoryRoutes(modules); + + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => + CategoryHomePage(category: category, modules: modules), + routes: childRoutes, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: CategoryWindowApp.createRouter(category), + title: category.label, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + ); + } +} + +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: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: modules.length, + itemBuilder: (context, index) => ModuleListTile(module: modules[index]), + ), + ); + } +} diff --git a/lib/app/module_home_page.dart b/lib/app/module_home_page.dart new file mode 100644 index 0000000..1bb10fa --- /dev/null +++ b/lib/app/module_home_page.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../module_registry/module_category.dart'; +import '../module_registry/module_entry.dart'; +import 'category_navigation.dart'; + +class ModuleHomePage extends StatelessWidget { + const ModuleHomePage({super.key, required this.modules}); + + final List modules; + + @override + Widget build(BuildContext context) { + const categories = ModuleCategory.values; + + return Scaffold( + appBar: AppBar(title: const Text('Flutter 学习实验室')), + body: 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.opensInNewWindow + ? Icons.open_in_new + : Icons.chevron_right, + size: 20, + ), + tooltip: '打开分类', + onPressed: () => CategoryNavigation.open( + context, + category: category, + modules: modules, + ), + ), + ], + ), + ), + ...categoryModules.map( + (module) => ModuleListTile(module: module), + ), + const Divider(height: 1), + ], + ); + }, + ), + ); + } +} + +class ModuleListTile extends StatelessWidget { + const ModuleListTile({super.key, required this.module}); + + final ModuleEntry module; + + Color _difficultyColor(Difficulty difficulty) { + return switch (difficulty) { + Difficulty.beginner => Colors.green, + Difficulty.intermediate => Colors.orange, + Difficulty.advanced => Colors.red, + }; + } + + @override + Widget build(BuildContext context) { + final difficultyColor = _difficultyColor(module.difficulty); + + return ListTile( + title: Row( + children: [ + Expanded(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, + ), + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(module.subtitle, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: module.concepts + .map( + (concept) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + 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), + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(module.path), + ); + } +} diff --git a/lib/app/router/AI_ANALYSIS.md b/lib/app/router/AI_ANALYSIS.md index 1714943..7f7606d 100644 --- a/lib/app/router/AI_ANALYSIS.md +++ b/lib/app/router/AI_ANALYSIS.md @@ -15,9 +15,10 @@ "owns": [ "go_router_root", "module_route_aggregation", - "module_home_index" + "module_catalog_composition" ], "depends": [ + "app/module_home_page", "module_registry", "modules" ], @@ -26,8 +27,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/app/router/app_route_table.dart b/lib/app/router/app_route_table.dart index 32e113c..b61b18a 100644 --- a/lib/app/router/app_route_table.dart +++ b/lib/app/router/app_route_table.dart @@ -1,11 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../module_home_page.dart'; import '../../module_registry/module_category.dart'; import '../../module_registry/module_entry.dart'; -import '../../shared/multi_window/category_window_app.dart'; -import '../../shared/multi_window/multi_window_manager.dart'; -import '../../shared/multi_window/multi_window_route_filter.dart'; import '../../modules/basic/debounce_throttle/module_entry.dart'; import '../../modules/basic/microtask/module_entry.dart'; import '../../modules/basic/microtask/module_routes.dart'; @@ -34,20 +31,21 @@ 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/online_video_player/module_entry.dart'; import '../../modules/platform/usb_detector/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(); +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(); // ==================== 模块注册 ==================== @@ -233,7 +231,7 @@ final List _modules = [ 'LayerLink', 'CompositedTransformFollower', 'markNeedsBuild', - 'ScrollController' + 'ScrollController', ], estimatedMinutes: 30, status: ModuleStatus.ready, @@ -264,26 +262,19 @@ final List _modules = [ status: ModuleStatus.ready, builder: (context) => const UsbDetectorEntry(), ), + ModuleEntry( + title: '在线视频播放', + path: '/online-video-player', + subtitle: '使用 media_kit 播放在线 HTTP 视频流并操控播放参数', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['media_kit', 'libmpv', 'HTTP 流', '播放控制', '倍速', 'Player 生命周期'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + builder: (context) => const OnlineVideoPlayerEntry(), + ), ]; -// ==================== 路由聚合 ==================== - -Future _openCategoryWindow( - BuildContext context, - ModuleCategory category, -) async { - final filtered = filterModulesByCategory(_modules, category); - if (MultiWindowManager.isSupported) { - await MultiWindowManager.instance.createCategoryWindow(category); - } else { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CategoryHomePage(category: category, modules: filtered), - ), - ); - } -} - final List _routes = [ GoRoute( path: '/', @@ -301,137 +292,3 @@ class AppRouteTable { static List get routes => _routes; static List get modules => _modules; } - -// ==================== 首页 ==================== - -class ModuleHomePage extends StatelessWidget { - const ModuleHomePage({super.key, required this.modules}); - - final List modules; - - @override - Widget build(BuildContext context) { - const categories = ModuleCategory.values; - - return Scaffold( - appBar: AppBar( - title: const Text('Flutter 学习实验室'), - ), - body: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - final categoryModules = - modules.where((m) => m.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: const Icon(Icons.open_in_new, size: 20), - tooltip: '在新窗口打开', - onPressed: () { - _openCategoryWindow(context, category); - }, - ), - ], - ), - ), - ...categoryModules.map((module) => ModuleCard(module: module)), - const Divider(height: 1), - ], - ); - }, - ), - ); - } -} - -class ModuleCard extends StatelessWidget { - const ModuleCard({super.key, required this.module}); - - final ModuleEntry module; - - Color _difficultyColor(Difficulty d) { - return switch (d) { - Difficulty.beginner => Colors.green, - Difficulty.intermediate => Colors.orange, - Difficulty.advanced => Colors.red, - }; - } - - @override - Widget build(BuildContext context) { - return ListTile( - title: Row( - children: [ - Expanded(child: Text(module.title)), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: - _difficultyColor(module.difficulty).withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - module.difficulty.label, - style: TextStyle( - fontSize: 11, - color: _difficultyColor(module.difficulty), - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(module.subtitle, style: const TextStyle(fontSize: 12)), - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 4, - children: module.concepts - .map( - (c) => Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(4), - ), - child: Text(c, style: const TextStyle(fontSize: 10)), - ), - ) - .toList(), - ), - const SizedBox(height: 4), - Text( - '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', - style: TextStyle(fontSize: 11, color: Colors.grey.shade600), - ), - ], - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(module.path), - ); - } -} diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 1292920..22ef56d 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -5,7 +5,5 @@ import 'app_route_table.dart'; class AppRouter { AppRouter._(); - static final GoRouter router = GoRouter( - routes: AppRouteTable.routes, - ); + static final GoRouter router = GoRouter(routes: AppRouteTable.routes); } diff --git a/lib/main.dart b/lib/main.dart index 3ab8e8d..8e7ac24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,36 +1,3 @@ -import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app/app_bootstrap.dart'; -import 'app/app.dart'; -import 'shared/multi_window/category_window_app.dart'; -import 'shared/multi_window/multi_window_manager.dart'; - -void main(List args) async { - WidgetsFlutterBinding.ensureInitialized(); - - if (MultiWindowManager.isSupported) { - final wc = await WindowController.fromCurrentEngine(); - final windowArgs = MultiWindowManager.parseArguments(wc.arguments); - - if (windowArgs.type == WindowType.category && windowArgs.category != null) { - runApp( - ProviderScope( - child: CategoryWindowApp(category: windowArgs.category!), - ), - ); - return; - } - } - - runApp(const ProviderScope(child: MainApp())); -} - -class MainApp extends StatelessWidget { - const MainApp({super.key}); - - @override - Widget build(BuildContext context) { - return const App(); - } -} +Future main() => bootstrapFlutterStudyApp(); diff --git a/lib/module_registry/AI_ANALYSIS.md b/lib/module_registry/AI_ANALYSIS.md index c0f53f3..916c8db 100644 --- a/lib/module_registry/AI_ANALYSIS.md +++ b/lib/module_registry/AI_ANALYSIS.md @@ -10,24 +10,28 @@ }, "entrypoints": [ "module_entry.dart", - "module_category.dart" + "module_category.dart", + "module_catalog_utils.dart" ], "owns": [ "module_entry_model", "module_category_enum", "difficulty_enum", - "module_status_enum" + "module_status_enum", + "module_catalog_filtering", + "category_route_rebasing" ], "depends": [ - "flutter_material" + "flutter_material", + "go_router" ], "children": [], "contracts": { "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/shared/multi_window/multi_window_route_filter.dart b/lib/module_registry/module_catalog_utils.dart similarity index 67% rename from lib/shared/multi_window/multi_window_route_filter.dart rename to lib/module_registry/module_catalog_utils.dart index 7942b77..f1addf1 100644 --- a/lib/shared/multi_window/multi_window_route_filter.dart +++ b/lib/module_registry/module_catalog_utils.dart @@ -1,13 +1,13 @@ import 'package:go_router/go_router.dart'; -import '../../module_registry/module_category.dart'; -import '../../module_registry/module_entry.dart'; +import 'module_category.dart'; +import 'module_entry.dart'; List filterModulesByCategory( List allModules, ModuleCategory category, ) { - return allModules.where((m) => m.category == category).toList(); + return allModules.where((module) => module.category == category).toList(); } List buildCategoryRoutes(List modules) { @@ -26,12 +26,12 @@ String _stripLeadingSlash(String path) { } List _rebasedRoutes(List routes) { - return routes.map((r) { - final strippedPath = r.path.startsWith('/') ? r.path.substring(1) : r.path; + return routes.map((route) { + final strippedPath = _stripLeadingSlash(route.path); return GoRoute( path: strippedPath, - builder: r.builder, - routes: r.routes, + builder: route.builder, + routes: route.routes, ); }).toList(); } diff --git a/lib/modules/AI_ANALYSIS.md b/lib/modules/AI_ANALYSIS.md index a2fae24..e25ba75 100644 --- a/lib/modules/AI_ANALYSIS.md +++ b/lib/modules/AI_ANALYSIS.md @@ -36,8 +36,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/async/AI_ANALYSIS.md b/lib/modules/async/AI_ANALYSIS.md index 334c059..1391044 100644 --- a/lib/modules/async/AI_ANALYSIS.md +++ b/lib/modules/async/AI_ANALYSIS.md @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 9453ebe..7ce7a82 100644 --- a/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/async/isolate_basic/module_root.dart b/lib/modules/async/isolate_basic/module_root.dart index ded49c3..a780fb4 100644 --- a/lib/modules/async/isolate_basic/module_root.dart +++ b/lib/modules/async/isolate_basic/module_root.dart @@ -34,8 +34,10 @@ class HomePage extends StatelessWidget { ), child: const Column( children: [ - Text('测试说明:', - style: TextStyle(fontWeight: FontWeight.bold)), + Text( + '测试说明:', + style: TextStyle(fontWeight: FontWeight.bold), + ), SizedBox(height: 8), Text( '1. 两个页面执行相同的计算任务(查找素数)\n' @@ -51,8 +53,10 @@ class HomePage extends StatelessWidget { const SizedBox(height: 40), ElevatedButton( style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), backgroundColor: Colors.red[100], ), onPressed: () { @@ -63,8 +67,10 @@ class HomePage extends StatelessWidget { const SizedBox(height: 20), ElevatedButton( style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), backgroundColor: Colors.green[100], ), onPressed: () { @@ -77,22 +83,27 @@ class HomePage extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Isolate 与主线程的内存隔离机制', - '掌握 Isolate.spawn + SendPort/ReceivePort 通信模式', - '对比有/无 Isolate 时 UI 流畅度差异', - ]), - ConceptChips(concepts: [ - 'Isolate', - 'SendPort', - 'ReceivePort', - '并发', - 'UI 流畅度', - '耗时计算', - ]), + LearningObjectives( + objectives: [ + '理解 Isolate 与主线程的内存隔离机制', + '掌握 Isolate.spawn + SendPort/ReceivePort 通信模式', + '对比有/无 Isolate 时 UI 流畅度差异', + ], + ), + ConceptChips( + concepts: [ + 'Isolate', + 'SendPort', + 'ReceivePort', + '并发', + 'UI 流畅度', + '耗时计算', + ], + ), CodeSnippetCard( title: 'Isolate 基础用法', - code: 'final receivePort = ReceivePort();\n' + code: + 'final receivePort = ReceivePort();\n' 'await Isolate.spawn(\n' ' computeTask,\n' ' receivePort.sendPort,\n' @@ -102,11 +113,13 @@ class HomePage extends StatelessWidget { '});', explanation: 'Isolate.spawn 在新 Isolate 中执行函数,通过 Port 通信。', ), - CommonPitfalls(pitfalls: [ - 'Isolate 间不能共享变量 — 必须通过消息传递,无法直接访问主线程数据', - 'ReceivePort 需要及时关闭 — 不关闭会导致内存泄漏', - '大量小消息的性能开销 — 频繁跨 Isolate 通信可能得不偿失', - ]), + CommonPitfalls( + pitfalls: [ + 'Isolate 间不能共享变量 — 必须通过消息传递,无法直接访问主线程数据', + 'ReceivePort 需要及时关闭 — 不关闭会导致内存泄漏', + '大量小消息的性能开销 — 频繁跨 Isolate 通信可能得不偿失', + ], + ), ExerciseCard( task: '修改 without_isolate_page.dart,尝试使用 compute 工具函数替换 Isolate.spawn,对比两种 API 的差异。', diff --git a/lib/modules/async/isolate_basic/module_routes.dart b/lib/modules/async/isolate_basic/module_routes.dart index 8cef788..a2d454d 100644 --- a/lib/modules/async/isolate_basic/module_routes.dart +++ b/lib/modules/async/isolate_basic/module_routes.dart @@ -11,13 +11,10 @@ class IsolateTestRoutes { static const String withIsolate = '/with-isolate'; static List get routes => [ - GoRoute( - path: 'without-isolate', - builder: (_, __) => const WithoutIsolatePage(), - ), - GoRoute( - path: 'with-isolate', - builder: (_, __) => const WithIsolatePage(), - ), - ]; + GoRoute( + path: 'without-isolate', + builder: (_, __) => const WithoutIsolatePage(), + ), + GoRoute(path: 'with-isolate', builder: (_, __) => const WithIsolatePage()), + ]; } diff --git a/lib/modules/async/isolate_basic/with_isolate_page.dart b/lib/modules/async/isolate_basic/with_isolate_page.dart index 960736f..3b6061a 100644 --- a/lib/modules/async/isolate_basic/with_isolate_page.dart +++ b/lib/modules/async/isolate_basic/with_isolate_page.dart @@ -61,8 +61,9 @@ class _WithIsolatePageState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - onPressed: _incrementCounter, - child: const Text('点击测试响应')), + onPressed: _incrementCounter, + child: const Text('点击测试响应'), + ), const SizedBox(width: 20), Text('计数: $_counter', style: const TextStyle(fontSize: 18)), ], @@ -81,8 +82,10 @@ class _WithIsolatePageState extends State borderRadius: BorderRadius.circular(8), ), child: const Center( - child: Text('这个动画应该平滑运行', - style: TextStyle(color: Colors.white, fontSize: 16)), + child: Text( + '这个动画应该平滑运行', + style: TextStyle(color: Colors.white, fontSize: 16), + ), ), ); }, @@ -116,22 +119,27 @@ class _WithIsolatePageState extends State ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Isolate 在 Flutter 中的工作原理', - '掌握 Isolate.spawn 创建后台线程的方法', - '学会使用 SendPort/ReceivePort 进行 Isolate 通信', - ]), - ConceptChips(concepts: [ - 'Isolate', - '多线程', - 'SendPort', - 'ReceivePort', - '并发计算', - 'UI 流畅度', - ]), + LearningObjectives( + objectives: [ + '理解 Isolate 在 Flutter 中的工作原理', + '掌握 Isolate.spawn 创建后台线程的方法', + '学会使用 SendPort/ReceivePort 进行 Isolate 通信', + ], + ), + ConceptChips( + concepts: [ + 'Isolate', + '多线程', + 'SendPort', + 'ReceivePort', + '并发计算', + 'UI 流畅度', + ], + ), CodeSnippetCard( title: 'Isolate 基本使用', - code: 'final receivePort = ReceivePort();\n' + code: + 'final receivePort = ReceivePort();\n' 'await Isolate.spawn(entryPoint, message,\n' ' onError: errorPort.sendPort);\n' 'await for (final msg in receivePort) {\n' @@ -203,11 +211,13 @@ class _WithIsolatePageState extends State void _isolateEntryPoint(_IsolateMessage message) { for (int i = 0; i < message.iterations; i++) { List primes = _calculatePrimes(message.maxNumber); - message.sendPort.send(_ProgressMessage( - iteration: i + 1, - progress: i / message.iterations, - primeCount: primes.length, - )); + message.sendPort.send( + _ProgressMessage( + iteration: i + 1, + progress: i / message.iterations, + primeCount: primes.length, + ), + ); } message.sendPort.send(_ResultMessage()); } diff --git a/lib/modules/async/isolate_basic/without_isolate_page.dart b/lib/modules/async/isolate_basic/without_isolate_page.dart index 6387880..a71cd73 100644 --- a/lib/modules/async/isolate_basic/without_isolate_page.dart +++ b/lib/modules/async/isolate_basic/without_isolate_page.dart @@ -60,8 +60,9 @@ class _WithoutIsolatePageState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - onPressed: _incrementCounter, - child: const Text('点击测试响应')), + onPressed: _incrementCounter, + child: const Text('点击测试响应'), + ), const SizedBox(width: 20), Text('计数: $_counter', style: const TextStyle(fontSize: 18)), ], @@ -80,8 +81,10 @@ class _WithoutIsolatePageState extends State borderRadius: BorderRadius.circular(8), ), child: const Center( - child: Text('这个动画应该平滑运行', - style: TextStyle(color: Colors.white, fontSize: 16)), + child: Text( + '这个动画应该平滑运行', + style: TextStyle(color: Colors.white, fontSize: 16), + ), ), ); }, @@ -115,21 +118,18 @@ class _WithoutIsolatePageState extends State ), ), sections: [ - LearningObjectives(objectives: [ - '理解主 Isolate 被阻塞时界面卡顿的原理', - '对比使用和不使用 Isolate 时的界面响应差异', - '掌握计算密集型任务对 UI 性能的影响', - ]), - ConceptChips(concepts: [ - 'Isolate', - '主线程', - 'UI 卡顿', - '计算密集型', - '事件循环', - ]), + LearningObjectives( + objectives: [ + '理解主 Isolate 被阻塞时界面卡顿的原理', + '对比使用和不使用 Isolate 时的界面响应差异', + '掌握计算密集型任务对 UI 性能的影响', + ], + ), + ConceptChips(concepts: ['Isolate', '主线程', 'UI 卡顿', '计算密集型', '事件循环']), CodeSnippetCard( title: '主线程计算的问题', - code: '// 主线程执行大量计算\n' + code: + '// 主线程执行大量计算\n' 'void _findPrimes() {\n' ' for (int i = 0; i < 20; i++) {\n' ' _calculatePrimes(500000); // 阻塞 UI\n' diff --git a/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index 4a12fec..4ac72d2 100644 --- a/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/async/isolate_task_manager/module_root.dart b/lib/modules/async/isolate_task_manager/module_root.dart index e62048a..a45dacc 100644 --- a/lib/modules/async/isolate_task_manager/module_root.dart +++ b/lib/modules/async/isolate_task_manager/module_root.dart @@ -86,12 +86,15 @@ class _MultiTaskIsolatePageState extends State { children: [ Text( task.name, - style: - Theme.of(context).textTheme.headlineSmall, + style: Theme.of( + context, + ).textTheme.headlineSmall, ), if (task.isCompleted) - const Icon(Icons.check_circle, - color: Colors.green) + const Icon( + Icons.check_circle, + color: Colors.green, + ) else IconButton( icon: const Icon(Icons.close), @@ -116,14 +119,14 @@ class _MultiTaskIsolatePageState extends State { task.isCompleted ? '已完成' : task.isPaused - ? '已暂停' - : '进行中', + ? '已暂停' + : '进行中', style: TextStyle( color: task.isCompleted ? Colors.green : task.isPaused - ? Colors.orange - : Colors.blue, + ? Colors.orange + : Colors.blue, fontWeight: FontWeight.bold, ), ), @@ -134,9 +137,11 @@ class _MultiTaskIsolatePageState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - icon: Icon(task.isPaused - ? Icons.play_arrow - : Icons.pause), + icon: Icon( + task.isPaused + ? Icons.play_arrow + : Icons.pause, + ), onPressed: task.isPaused ? () => _resumeTask(task) : () => _pauseTask(task), @@ -152,22 +157,20 @@ class _MultiTaskIsolatePageState extends State { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Isolate 多任务并行执行的原理', - '掌握通过 Stream 实时监控任务进度', - '学会管理多个 Isolate 的生命周期', - ]), - ConceptChips(concepts: [ - 'Isolate', - '多任务', - 'Stream', - '进度上报', - '暂停/恢复', - '并发控制', - ]), + LearningObjectives( + objectives: [ + '理解 Isolate 多任务并行执行的原理', + '掌握通过 Stream 实时监控任务进度', + '学会管理多个 Isolate 的生命周期', + ], + ), + ConceptChips( + concepts: ['Isolate', '多任务', 'Stream', '进度上报', '暂停/恢复', '并发控制'], + ), CodeSnippetCard( title: 'TaskManager 核心用法', - code: 'final manager = TaskManager(\n' + code: + 'final manager = TaskManager(\n' ' onTaskUpdate: (task) => setState(() {}),\n' ' onTaskComplete: (task) => setState(() {}),\n' ');\n' @@ -178,11 +181,13 @@ class _MultiTaskIsolatePageState extends State { 'manager.dispose();', explanation: 'TaskManager 封装了 Isolate 的创建、通信和销毁流程。', ), - CommonPitfalls(pitfalls: [ - '忘记 dispose TaskManager — Isolate 不会自动终止,需显式释放资源', - '在 Isolate 中访问主线程对象 — Isolate 是独立内存空间,只能通过消息传递数据', - '任务过密导致 UI 卡顿 — 大量任务同时运行时注意控制并发数量', - ]), + CommonPitfalls( + pitfalls: [ + '忘记 dispose TaskManager — Isolate 不会自动终止,需显式释放资源', + '在 Isolate 中访问主线程对象 — Isolate 是独立内存空间,只能通过消息传递数据', + '任务过密导致 UI 卡顿 — 大量任务同时运行时注意控制并发数量', + ], + ), ExerciseCard( task: '为 TaskManager 增加"任务优先级"功能,高优先级任务先执行。', hint: '在 Task 模型中增加 priority 字段,在 startNewTask 中对队列排序。', diff --git a/lib/modules/async/isolate_task_manager/task_manager.dart b/lib/modules/async/isolate_task_manager/task_manager.dart index 57e1eeb..9f1b384 100644 --- a/lib/modules/async/isolate_task_manager/task_manager.dart +++ b/lib/modules/async/isolate_task_manager/task_manager.dart @@ -68,10 +68,7 @@ class TaskManager { // 创建并启动新任务 Task startNewTask() { - final task = Task( - id: _nextTaskId++, - name: '任务 ${_nextTaskId - 1}', - ); + final task = Task(id: _nextTaskId++, name: '任务 ${_nextTaskId - 1}'); _tasks.add(task); _startTaskInIsolate(task); @@ -84,14 +81,11 @@ class TaskManager { task.receivePort = ReceivePort(); // 启动Isolate,传递任务ID、初始进度和sendPort - task.isolate = await Isolate.spawn( - _taskIsolate, - { - 'sendPort': task.receivePort!.sendPort, - 'taskId': task.id, - 'initialProgress': task.progress, - }, - ); + task.isolate = await Isolate.spawn(_taskIsolate, { + 'sendPort': task.receivePort!.sendPort, + 'taskId': task.id, + 'initialProgress': task.progress, + }); // 监听任务进度更新和Isolate的SendPort task.subscription = task.receivePort!.listen((dynamic data) { @@ -100,8 +94,9 @@ class TaskManager { task.isolateSendPort = data; } else if (data is Map) { final int taskId = data['taskId']; - final Task? updatedTask = - _tasks.firstWhereOrNull((t) => t.id == taskId); + final Task? updatedTask = _tasks.firstWhereOrNull( + (t) => t.id == taskId, + ); if (updatedTask != null) { if (data.containsKey('progress')) { @@ -195,11 +190,7 @@ void _taskIsolate(dynamic message) { // 任务完成时停止定时器 if (progress >= 100 || t.tick >= totalSteps) { - sendPort.send({ - 'taskId': taskId, - 'progress': 100, - 'completed': true, - }); + sendPort.send({'taskId': taskId, 'progress': 100, 'completed': true}); t.cancel(); isRunning = false; commandPort.close(); diff --git a/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/lib/modules/async/stream_subscription/AI_ANALYSIS.md index d4b7747..c82383f 100644 --- a/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/async/stream_subscription/module_routes.dart b/lib/modules/async/stream_subscription/module_routes.dart index 61c6c4a..541a9c2 100644 --- a/lib/modules/async/stream_subscription/module_routes.dart +++ b/lib/modules/async/stream_subscription/module_routes.dart @@ -11,13 +11,10 @@ class StreamSubscriptionRoutes { static const String broadcastDemo = '/broadcast-demo'; static List get routes => [ - GoRoute( - path: 'stream-demo', - builder: (_, __) => const StreamDemoPage(), - ), - GoRoute( - path: 'broadcast-demo', - builder: (_, __) => const BroadcastDemoPage(), - ), - ]; + GoRoute(path: 'stream-demo', builder: (_, __) => const StreamDemoPage()), + GoRoute( + path: 'broadcast-demo', + builder: (_, __) => const BroadcastDemoPage(), + ), + ]; } diff --git a/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart b/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart index 40d8e39..45a281d 100644 --- a/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart +++ b/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart @@ -125,8 +125,9 @@ class _BroadcastDemoPageState extends State { } void _showMessage(String message) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } @override @@ -156,9 +157,13 @@ class _BroadcastDemoPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('控制面板', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), + Text( + '控制面板', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -219,8 +224,10 @@ class _BroadcastDemoPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( '订阅者 (${_subscribers.length}):', - style: - const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), ), const SizedBox(height: 4), @@ -239,8 +246,10 @@ class _BroadcastDemoPageState extends State { ListTile( title: Text(subscriber.name), trailing: IconButton( - icon: const Icon(Icons.delete, - color: Colors.red), + icon: const Icon( + Icons.delete, + color: Colors.red, + ), onPressed: () => _removeSubscriber(subscriber.id), ), @@ -249,7 +258,8 @@ class _BroadcastDemoPageState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - '收到的消息 (${subscriber.messages.length}):'), + '收到的消息 (${subscriber.messages.length}):', + ), ), SizedBox( height: 80, @@ -260,7 +270,9 @@ class _BroadcastDemoPageState extends State { itemBuilder: (context, messageIndex) { return Padding( padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 2), + horizontal: 16, + vertical: 2, + ), child: Text( subscriber.messages[messageIndex], style: TextStyle( @@ -294,21 +306,20 @@ class _BroadcastDemoPageState extends State { ), ), sections: [ - LearningObjectives(objectives: [ - '理解广播 Stream 的多订阅者特性', - '掌握 StreamController.broadcast() 的创建与使用', - '学会管理多个 StreamSubscription 的生命周期', - ]), - ConceptChips(concepts: [ - '广播 Stream', - '多订阅者', - 'StreamController', - '错误处理', - '定时推送', - ]), + LearningObjectives( + objectives: [ + '理解广播 Stream 的多订阅者特性', + '掌握 StreamController.broadcast() 的创建与使用', + '学会管理多个 StreamSubscription 的生命周期', + ], + ), + ConceptChips( + concepts: ['广播 Stream', '多订阅者', 'StreamController', '错误处理', '定时推送'], + ), CodeSnippetCard( title: '广播模式核心代码', - code: 'final controller = StreamController.broadcast(\n' + code: + 'final controller = StreamController.broadcast(\n' ' onListen: () => print("首次订阅"),\n' ' onCancel: () => print("末次取消"),\n' ');\n' @@ -317,11 +328,13 @@ class _BroadcastDemoPageState extends State { 'controller.add("hello"); // 两个订阅者都收到', explanation: '广播流允许任意数量的监听器同时订阅同一个数据源。', ), - CommonPitfalls(pitfalls: [ - '广播 Stream 没有缓存 — 在订阅前发送的消息会被丢失', - 'onListen 只在第一个订阅者加入时触发,onCancel 在最后一个离开时触发', - 'close() 后不能再用 add(),需重新创建 StreamController', - ]), + CommonPitfalls( + pitfalls: [ + '广播 Stream 没有缓存 — 在订阅前发送的消息会被丢失', + 'onListen 只在第一个订阅者加入时触发,onCancel 在最后一个离开时触发', + 'close() 后不能再用 add(),需重新创建 StreamController', + ], + ), ExerciseCard( task: '为每个订阅者设置独立的过滤器,让其只接收包含特定关键词的消息。', hint: diff --git a/lib/modules/async/stream_subscription/pages/home_page.dart b/lib/modules/async/stream_subscription/pages/home_page.dart index e4cd66c..601aa44 100644 --- a/lib/modules/async/stream_subscription/pages/home_page.dart +++ b/lib/modules/async/stream_subscription/pages/home_page.dart @@ -41,32 +41,37 @@ class HomePage extends StatelessWidget { icon: Icons.transform, color: Colors.orange, onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('此功能暂未实现,敬请期待!')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('此功能暂未实现,敬请期待!'))); }, ), ], ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Flutter Stream 的概念与工作原理', - '掌握单订阅 Stream 和广播 Stream 的区别', - '学会使用 StreamController 创建和管理数据流', - '理解 StreamSubscription 的生命周期管理', - ]), - ConceptChips(concepts: [ - 'Stream', - 'StreamController', - 'StreamSubscription', - '单订阅', - '广播', - '异步数据流', - ]), + LearningObjectives( + objectives: [ + '理解 Flutter Stream 的概念与工作原理', + '掌握单订阅 Stream 和广播 Stream 的区别', + '学会使用 StreamController 创建和管理数据流', + '理解 StreamSubscription 的生命周期管理', + ], + ), + ConceptChips( + concepts: [ + 'Stream', + 'StreamController', + 'StreamSubscription', + '单订阅', + '广播', + '异步数据流', + ], + ), CodeSnippetCard( title: '创建广播 Stream', - code: 'final controller = StreamController.broadcast();\n' + code: + 'final controller = StreamController.broadcast();\n' 'controller.stream.listen((data) {\n' ' print("收到: \$data");\n' '});\n' @@ -74,11 +79,13 @@ class HomePage extends StatelessWidget { 'controller.close();', explanation: 'broadcast() 创建多订阅者流,支持一对多推送。', ), - CommonPitfalls(pitfalls: [ - '单订阅 Stream 只能有一个监听器 — 添加第二个会抛出 StateError', - 'Stream 使用后必须 close() — 否则会导致内存泄漏', - '广播 Stream 的 onListen/onCancel 回调在第一个/最后一个监听器时触发', - ]), + CommonPitfalls( + pitfalls: [ + '单订阅 Stream 只能有一个监听器 — 添加第二个会抛出 StateError', + 'Stream 使用后必须 close() — 否则会导致内存泄漏', + '广播 Stream 的 onListen/onCancel 回调在第一个/最后一个监听器时触发', + ], + ), ExerciseCard( task: '实现一个带错误处理和 done 回调的 Stream,模拟三次数据推送后自动关闭。', hint: @@ -120,13 +127,21 @@ class HomePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 4), - Text(subtitle, - style: TextStyle( - fontSize: 14, color: Colors.grey.shade600)), + Text( + subtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), ], ), ), diff --git a/lib/modules/async/stream_subscription/pages/stream_demo_page.dart b/lib/modules/async/stream_subscription/pages/stream_demo_page.dart index a97e6c6..cefb32e 100644 --- a/lib/modules/async/stream_subscription/pages/stream_demo_page.dart +++ b/lib/modules/async/stream_subscription/pages/stream_demo_page.dart @@ -101,7 +101,9 @@ _controller.close();''', return Text( controller.messages[index], style: const TextStyle( - fontFamily: 'monospace', fontSize: 12), + fontFamily: 'monospace', + fontSize: 12, + ), ); }, ), @@ -133,15 +135,18 @@ _controller.close();''', children: [ Row( children: [ - Icon(Icons.tune, - size: 18, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.tune, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: 8), Text( '控制面板', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), ), ], ), @@ -163,8 +168,9 @@ _controller.close();''', child: const Text('订阅'), ), ElevatedButton( - onPressed: - controller.isSubscribed ? null : controller.unsubscribe, + onPressed: controller.isSubscribed + ? null + : controller.unsubscribe, child: const Text('取消订阅'), ), ElevatedButton( diff --git a/lib/modules/async/stream_subscription/utils/stream_utils.dart b/lib/modules/async/stream_subscription/utils/stream_utils.dart index eff8abc..098faaa 100644 --- a/lib/modules/async/stream_subscription/utils/stream_utils.dart +++ b/lib/modules/async/stream_subscription/utils/stream_utils.dart @@ -75,15 +75,17 @@ class StreamUtils { // 当所有Stream都完成时,关闭控制器 var completedCount = 0; for (var stream in streams) { - stream.listen( - null, - onDone: () { - completedCount++; - if (completedCount == streams.length) { - controller.close(); - } - }, - ).cancel(); // 立即取消这个监听,因为我们只关心完成事件 + stream + .listen( + null, + onDone: () { + completedCount++; + if (completedCount == streams.length) { + controller.close(); + } + }, + ) + .cancel(); // 立即取消这个监听,因为我们只关心完成事件 } controller.onCancel = () { @@ -180,8 +182,10 @@ class StreamUtils { } /// 创建一个当数据发生变化时才发送的Stream - static Stream distinct(Stream stream, - {bool Function(T previous, T current)? equals}) { + static Stream distinct( + Stream stream, { + bool Function(T previous, T current)? equals, + }) { final controller = StreamController(); T? previousValue; bool isFirst = true; diff --git a/lib/modules/basic/AI_ANALYSIS.md b/lib/modules/basic/AI_ANALYSIS.md index 4a3048d..6b03944 100644 --- a/lib/modules/basic/AI_ANALYSIS.md +++ b/lib/modules/basic/AI_ANALYSIS.md @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index 6da03d8..88d2723 100644 --- a/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/basic/debounce_throttle/module_root.dart b/lib/modules/basic/debounce_throttle/module_root.dart index 89aecd7..fbf0c84 100644 --- a/lib/modules/basic/debounce_throttle/module_root.dart +++ b/lib/modules/basic/debounce_throttle/module_root.dart @@ -44,9 +44,11 @@ class _MyHomePageState extends State with TickerProviderStateMixin { ), onPressed: () { setState(() => _currentPageIndex = 0); - _pageController.animateToPage(0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, child: const Text('按钮点击场景'), ), @@ -61,9 +63,11 @@ class _MyHomePageState extends State with TickerProviderStateMixin { ), onPressed: () { setState(() => _currentPageIndex = 1); - _pageController.animateToPage(1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); + _pageController.animateToPage( + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, child: const Text('滚动场景'), ), @@ -79,13 +83,17 @@ class _MyHomePageState extends State with TickerProviderStateMixin { Text( '防抖(Debounce):在一段时间内多次触发事件,只执行最后一次。', style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.red), + fontWeight: FontWeight.bold, + color: Colors.red, + ), ), SizedBox(height: 8), Text( '节流(Throttle):在一段时间内多次触发事件,只执行第一次。', style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.blue), + fontWeight: FontWeight.bold, + color: Colors.blue, + ), ), ], ), @@ -96,31 +104,27 @@ class _MyHomePageState extends State with TickerProviderStateMixin { onPageChanged: (index) { setState(() => _currentPageIndex = index); }, - children: const [ - ButtonScene(), - ScrollScene(), - ], + children: const [ButtonScene(), ScrollScene()], ), ), ], ), ), sections: [ - LearningObjectives(objectives: [ - '理解防抖(Debounce)与节流(Throttle)的核心区别', - '掌握 Debouncer 和 Throttle 的代码实现', - '学会在实际场景中选择合适的频率控制策略', - ]), - ConceptChips(concepts: [ - 'Debounce', - 'Throttle', - 'Timer', - '频率控制', - '性能优化', - ]), + LearningObjectives( + objectives: [ + '理解防抖(Debounce)与节流(Throttle)的核心区别', + '掌握 Debouncer 和 Throttle 的代码实现', + '学会在实际场景中选择合适的频率控制策略', + ], + ), + ConceptChips( + concepts: ['Debounce', 'Throttle', 'Timer', '频率控制', '性能优化'], + ), CodeSnippetCard( title: 'Debouncer 实现', - code: 'class Debouncer {\n' + code: + 'class Debouncer {\n' ' final Duration delay;\n' ' Timer? _timer;\n\n' ' void run(VoidCallback action) {\n' @@ -133,7 +137,8 @@ class _MyHomePageState extends State with TickerProviderStateMixin { ), CodeSnippetCard( title: 'Throttle 实现', - code: 'class Throttle {\n' + code: + 'class Throttle {\n' ' final Duration limit;\n' ' DateTime? _lastCall;\n\n' ' void run(VoidCallback action) {\n' @@ -146,11 +151,13 @@ class _MyHomePageState extends State with TickerProviderStateMixin { '}', explanation: '节流在限制时间内忽略后续触发,只有第一次生效。', ), - CommonPitfalls(pitfalls: [ - '防抖延迟过长会降低响应感 — 按钮点击场景建议 300-500ms,滚动场景可适当延长', - '节流可能会导致关键更新丢失 — 不适合需要实时反馈的场景', - '忘记 dispose — Timer 和 StreamSubscription 必须在 dispose 中清理', - ]), + CommonPitfalls( + pitfalls: [ + '防抖延迟过长会降低响应感 — 按钮点击场景建议 300-500ms,滚动场景可适当延长', + '节流可能会导致关键更新丢失 — 不适合需要实时反馈的场景', + '忘记 dispose — Timer 和 StreamSubscription 必须在 dispose 中清理', + ], + ), ExerciseCard( task: '实现一个"先执行一次"的防抖(leading edge debounce),首次点击立即执行,后续连续点击只执行最后一次。', hint: '在 Debouncer 中增加 _leadingExecuted 标记,首次调用时立即执行再启动延迟。', @@ -181,10 +188,12 @@ class _ButtonSceneState extends State late AnimationController _debounceAnim; late AnimationController _throttleAnim; - final Debouncer _debouncer = - Debouncer(delay: const Duration(milliseconds: 500)); - final Throttle _throttler = - Throttle(limit: const Duration(milliseconds: 500)); + final Debouncer _debouncer = Debouncer( + delay: const Duration(milliseconds: 500), + ); + final Throttle _throttler = Throttle( + limit: const Duration(milliseconds: 500), + ); @override void initState() { @@ -259,11 +268,23 @@ class _ButtonSceneState extends State mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildAnimatedButton( - '普通点击', Colors.grey, _handleNormalClick, _normalAnim), + '普通点击', + Colors.grey, + _handleNormalClick, + _normalAnim, + ), _buildAnimatedButton( - '防抖点击', Colors.red, _handleDebounceClick, _debounceAnim), + '防抖点击', + Colors.red, + _handleDebounceClick, + _debounceAnim, + ), _buildAnimatedButton( - '节流点击', Colors.blue, _handleThrottleClick, _throttleAnim), + '节流点击', + Colors.blue, + _handleThrottleClick, + _throttleAnim, + ), ], ), const SizedBox(height: 20), @@ -276,8 +297,10 @@ class _ButtonSceneState extends State ], ), const SizedBox(height: 30), - const Text('事件触发可视化:', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Text( + '事件触发可视化:', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), const SizedBox(height: 10), Expanded( child: Row( @@ -295,7 +318,11 @@ class _ButtonSceneState extends State } Widget _buildAnimatedButton( - String text, Color color, VoidCallback onTap, AnimationController anim) { + String text, + Color color, + VoidCallback onTap, + AnimationController anim, + ) { return ScaleTransition( scale: CurvedAnimation(parent: anim, curve: Curves.elasticOut), child: ElevatedButton( @@ -313,9 +340,14 @@ class _ButtonSceneState extends State Widget _buildCounter(String prefix, int count, Color color) { return Column( children: [ - Text('$prefix $count', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold, color: color)), + Text( + '$prefix $count', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), ], ); } @@ -330,8 +362,10 @@ class _ButtonSceneState extends State ), child: Column( children: [ - Text(title, - style: TextStyle(color: color, fontWeight: FontWeight.bold)), + Text( + title, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), const SizedBox(height: 10), Expanded( child: ListView.builder( @@ -339,9 +373,9 @@ class _ButtonSceneState extends State reverse: true, itemBuilder: (context, index) { return Text( - DateTime.fromMillisecondsSinceEpoch(events[index]) - .toString() - .substring(11, 19), + DateTime.fromMillisecondsSinceEpoch( + events[index], + ).toString().substring(11, 19), style: TextStyle(color: color.withValues(alpha: 0.8)), ); }, @@ -370,10 +404,12 @@ class _ScrollSceneState extends State final ScrollController _scrollController = ScrollController(); late AnimationController _positionAnimController; - final Debouncer _debouncer = - Debouncer(delay: const Duration(milliseconds: 500)); - final Throttle _throttler = - Throttle(limit: const Duration(milliseconds: 500)); + final Debouncer _debouncer = Debouncer( + delay: const Duration(milliseconds: 500), + ); + final Throttle _throttler = Throttle( + limit: const Duration(milliseconds: 500), + ); @override void initState() { @@ -414,18 +450,27 @@ class _ScrollSceneState extends State child: Column( children: [ _buildPositionIndicator( - '实时位置', _scrollPosition, Colors.grey[800]!), + '实时位置', + _scrollPosition, + Colors.grey[800]!, + ), const SizedBox(height: 10), Row( children: [ Expanded( child: _buildAnimatedPositionIndicator( - '防抖位置', _debouncePosition, Colors.red), + '防抖位置', + _debouncePosition, + Colors.red, + ), ), const SizedBox(width: 20), Expanded( child: _buildAnimatedPositionIndicator( - '节流位置', _throttlePosition, Colors.blue), + '节流位置', + _throttlePosition, + Colors.blue, + ), ), ], ), @@ -452,14 +497,21 @@ class _ScrollSceneState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('列表项 #$index', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 16)), + Text( + '列表项 #$index', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), const SizedBox(height: 8), Row( children: [ _buildPositionDot( - '实时', isNearReal, Colors.grey[800]!), + '实时', + isNearReal, + Colors.grey[800]!, + ), _buildPositionDot('防抖', isNearDebounce, Colors.red), _buildPositionDot('节流', isNearThrottle, Colors.blue), ], @@ -480,8 +532,10 @@ class _ScrollSceneState extends State children: [ SizedBox( width: 100, - child: Text(title, - style: TextStyle(color: color, fontWeight: FontWeight.bold)), + child: Text( + title, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), ), Expanded( child: Container( @@ -492,11 +546,12 @@ class _ScrollSceneState extends State ), child: FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: (position / - (_scrollController.hasClients - ? _scrollController.position.maxScrollExtent - : 1)) - .clamp(0.0, 1.0), + widthFactor: + (position / + (_scrollController.hasClients + ? _scrollController.position.maxScrollExtent + : 1)) + .clamp(0.0, 1.0), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), @@ -509,21 +564,29 @@ class _ScrollSceneState extends State const SizedBox(width: 8), SizedBox( width: 60, - child: Text('${position.toStringAsFixed(0)}px', - textAlign: TextAlign.right, style: TextStyle(color: color)), + child: Text( + '${position.toStringAsFixed(0)}px', + textAlign: TextAlign.right, + style: TextStyle(color: color), + ), ), ], ); } Widget _buildAnimatedPositionIndicator( - String title, double position, Color color) { + String title, + double position, + Color color, + ) { return Row( children: [ SizedBox( width: 80, - child: Text(title, - style: TextStyle(color: color, fontWeight: FontWeight.bold)), + child: Text( + title, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), ), Expanded( child: Stack( @@ -542,7 +605,8 @@ class _ScrollSceneState extends State ), child: Container( height: 12, - width: (position / + width: + (position / (_scrollController.hasClients ? _scrollController.position.maxScrollExtent : 1)) * @@ -560,8 +624,11 @@ class _ScrollSceneState extends State const SizedBox(width: 8), SizedBox( width: 60, - child: Text('${position.toStringAsFixed(0)}px', - textAlign: TextAlign.right, style: TextStyle(color: color)), + child: Text( + '${position.toStringAsFixed(0)}px', + textAlign: TextAlign.right, + style: TextStyle(color: color), + ), ), ], ); @@ -582,11 +649,13 @@ class _ScrollSceneState extends State ), ), const SizedBox(width: 4), - Text(label, - style: TextStyle( - color: color, - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, - )), + Text( + label, + style: TextStyle( + color: color, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + ), ], ), ); diff --git a/lib/modules/basic/microtask/AI_ANALYSIS.md b/lib/modules/basic/microtask/AI_ANALYSIS.md index cc0bf38..257bd8f 100644 --- a/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/basic/microtask/models/event_log.dart b/lib/modules/basic/microtask/models/event_log.dart index 0282326..c500179 100644 --- a/lib/modules/basic/microtask/models/event_log.dart +++ b/lib/modules/basic/microtask/models/event_log.dart @@ -1,11 +1,6 @@ import 'package:flutter/material.dart'; -enum EventType { - sync, - microtask, - event, - info, -} +enum EventType { sync, microtask, event, info } class EventLog { final String message; @@ -13,11 +8,8 @@ class EventLog { final DateTime timestamp; final int id; - EventLog({ - required this.message, - required this.type, - required this.id, - }) : timestamp = DateTime.now(); + EventLog({required this.message, required this.type, required this.id}) + : timestamp = DateTime.now(); Color get color { switch (type) { diff --git a/lib/modules/basic/microtask/module_routes.dart b/lib/modules/basic/microtask/module_routes.dart index 1259242..18be254 100644 --- a/lib/modules/basic/microtask/module_routes.dart +++ b/lib/modules/basic/microtask/module_routes.dart @@ -12,17 +12,11 @@ class MicrotaskRoutes { static const String advanced = '/advanced'; static List get routes => [ - GoRoute( - path: 'event-queue', - builder: (_, __) => const EventQueuePage(), - ), - GoRoute( - path: 'microtask-queue', - builder: (_, __) => const MicrotaskQueuePage(), - ), - GoRoute( - path: 'advanced', - builder: (_, __) => const AdvancedExamplesPage(), - ), - ]; + GoRoute(path: 'event-queue', builder: (_, __) => const EventQueuePage()), + GoRoute( + path: 'microtask-queue', + builder: (_, __) => const MicrotaskQueuePage(), + ), + GoRoute(path: 'advanced', builder: (_, __) => const AdvancedExamplesPage()), + ]; } diff --git a/lib/modules/basic/microtask/pages/advanced_examples_page.dart b/lib/modules/basic/microtask/pages/advanced_examples_page.dart index 56bc215..51f02a9 100644 --- a/lib/modules/basic/microtask/pages/advanced_examples_page.dart +++ b/lib/modules/basic/microtask/pages/advanced_examples_page.dart @@ -82,11 +82,7 @@ runZoned(() { void _addLog(String message, EventType type) { if (!_isRunning) return; setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); + _logs.add(EventLog(message: message, type: type, id: _logs.length + 1)); }); scheduleMicrotask(_scrollToBottom); } @@ -116,8 +112,10 @@ runZoned(() { _addLog('await之前的代码', EventType.sync); await Future(() => _addLog('await的Future执行', EventType.event)); _addLog('await之后的代码', EventType.microtask); - await Future.delayed(const Duration(milliseconds: 500), - () => _addLog('第二个await的Future执行', EventType.event)); + await Future.delayed( + const Duration(milliseconds: 500), + () => _addLog('第二个await的Future执行', EventType.event), + ); _addLog('第二个await之后的代码', EventType.microtask); _addLog('async函数结束', EventType.microtask); } @@ -128,8 +126,9 @@ runZoned(() { _clearLogs(); _addLog('开始Future.value测试', EventType.info); _addLog('代码开始执行', EventType.sync); - Future.value('立即值').then( - (value) => _addLog('Future.value微任务: $value', EventType.microtask)); + Future.value( + '立即值', + ).then((value) => _addLog('Future.value微任务: $value', EventType.microtask)); Future(() { _addLog('普通Future事件任务执行', EventType.event); return '计算值'; @@ -152,9 +151,10 @@ runZoned(() { Future(() => _addLog('初始Future', EventType.event)) .then((_) => _addLog('第一个then微任务', EventType.microtask)) .then((_) { - _addLog('第二个then微任务', EventType.microtask); - return Future(() => _addLog('嵌套事件任务', EventType.event)); - }).then((_) => _addLog('第三个then微任务', EventType.microtask)); + _addLog('第二个then微任务', EventType.microtask); + return Future(() => _addLog('嵌套事件任务', EventType.event)); + }) + .then((_) => _addLog('第三个then微任务', EventType.microtask)); scheduleMicrotask(() => _addLog('独立的微任务', EventType.microtask)); Future(() => _addLog('独立的事件任务', EventType.event)); _addLog('代码结束执行', EventType.sync); @@ -170,22 +170,26 @@ runZoned(() { _clearLogs(); _addLog('开始Zone测试', EventType.info); _addLog('代码开始执行', EventType.sync); - runZoned(() { - _addLog('进入自定义Zone', EventType.sync); - Future(() => _addLog('在自定义Zone中执行的Future', EventType.event)); - scheduleMicrotask(() => _addLog('在自定义Zone中执行的微任务', EventType.microtask)); - _addLog('离开自定义Zone', EventType.sync); - }, - zoneSpecification: ZoneSpecification( - scheduleMicrotask: (self, parent, zone, f) { - _addLog('微任务被调度', EventType.info); - parent.scheduleMicrotask(zone, f); - }, - createTimer: (self, parent, zone, duration, f) { - _addLog('计时器被创建,持续时间: $duration', EventType.info); - return parent.createTimer(zone, duration, f); - }, - )); + runZoned( + () { + _addLog('进入自定义Zone', EventType.sync); + Future(() => _addLog('在自定义Zone中执行的Future', EventType.event)); + scheduleMicrotask( + () => _addLog('在自定义Zone中执行的微任务', EventType.microtask), + ); + _addLog('离开自定义Zone', EventType.sync); + }, + zoneSpecification: ZoneSpecification( + scheduleMicrotask: (self, parent, zone, f) { + _addLog('微任务被调度', EventType.info); + parent.scheduleMicrotask(zone, f); + }, + createTimer: (self, parent, zone, duration, f) { + _addLog('计时器被创建,持续时间: $duration', EventType.info); + return parent.createTimer(zone, duration, f); + }, + ), + ); Future(() => _addLog('在主Zone中执行的Future', EventType.event)); scheduleMicrotask(() => _addLog('在主Zone中执行的微任务', EventType.microtask)); _addLog('代码结束执行', EventType.sync); @@ -209,78 +213,75 @@ runZoned(() { children: [ SizedBox( height: 380, - child: DefaultTabController( - length: 4, - child: Column( - children: [ - TabBar( + child: Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'async/await'), + Tab(text: 'Future.value'), + Tab(text: 'Future链'), + Tab(text: 'Zone'), + ], + ), + Expanded( + child: TabBarView( controller: _tabController, - tabs: const [ - Tab(text: 'async/await'), - Tab(text: 'Future.value'), - Tab(text: 'Future链'), - Tab(text: 'Zone'), + children: [ + _buildTabContent( + 'async/await', + 'async/await语法是Future的语法糖。' + 'await会暂停函数并将后续代码包装成微任务。', + _runAsyncAwaitTest, + ), + _buildTabContent( + 'Future.value', + 'Future.value立即完成,then回调直接进微任务队列。', + _runFutureValueTest, + ), + _buildTabContent( + 'Future链式调用', + '每个then回调都是微任务,不是事件任务。', + _runFutureChainTest, + ), + _buildTabContent( + 'Zone', + 'Zone可拦截和修改异步操作调度。', + _runZoneTest, + ), ], ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildTabContent( - 'async/await', - 'async/await语法是Future的语法糖。' - 'await会暂停函数并将后续代码包装成微任务。', - _runAsyncAwaitTest, - ), - _buildTabContent( - 'Future.value', - 'Future.value立即完成,then回调直接进微任务队列。', - _runFutureValueTest, - ), - _buildTabContent( - 'Future链式调用', - '每个then回调都是微任务,不是事件任务。', - _runFutureChainTest, - ), - _buildTabContent( - 'Zone', - 'Zone可拦截和修改异步操作调度。', - _runZoneTest, - ), - ], - ), - ), - ], - ), + ), + ], ), ), const SizedBox(height: 4), Expanded( - child: EventLogView( - logs: _logs, - showTimestamp: _showTimestamps, - scrollController: _scrollController, - )), + child: EventLogView( + logs: _logs, + showTimestamp: _showTimestamps, + scrollController: _scrollController, + ), + ), ], ), ), sections: [ - LearningObjectives(objectives: [ - '理解 async/await 背后的微任务调度机制', - '掌握 Future.value 与普通 Future 的区别', - '理解 Future 链式调用中 then 回调的调度行为', - '了解 Zone 的异步操作拦截机制', - ]), - ConceptChips(concepts: [ - 'async/await', - 'Future.value', - '链式调用', - 'Zone', - '微任务调度', - ]), + LearningObjectives( + objectives: [ + '理解 async/await 背后的微任务调度机制', + '掌握 Future.value 与普通 Future 的区别', + '理解 Future 链式调用中 then 回调的调度行为', + '了解 Zone 的异步操作拦截机制', + ], + ), + ConceptChips( + concepts: ['async/await', 'Future.value', '链式调用', 'Zone', '微任务调度'], + ), CodeSnippetCard( title: 'async/await 调度原理', - code: 'void main() async {\n' + code: + 'void main() async {\n' ' print("1: 同步");\n' ' await Future(() => print("3: 事件"));\n' ' print("2: await后的微任务");\n' @@ -296,7 +297,10 @@ runZoned(() { } Widget _buildTabContent( - String title, String description, VoidCallback onRun) { + String title, + String description, + VoidCallback onRun, + ) { return SingleChildScrollView( padding: const EdgeInsets.all(8), child: Column( diff --git a/lib/modules/basic/microtask/pages/event_queue_page.dart b/lib/modules/basic/microtask/pages/event_queue_page.dart index 4be8e9b..4c7c739 100644 --- a/lib/modules/basic/microtask/pages/event_queue_page.dart +++ b/lib/modules/basic/microtask/pages/event_queue_page.dart @@ -37,11 +37,7 @@ class _EventQueuePageState extends State { void _addLog(String message, EventType type) { if (!_isRunning) return; setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); + _logs.add(EventLog(message: message, type: type, id: _logs.length + 1)); }); scheduleMicrotask(_scrollToBottom); } @@ -57,12 +53,18 @@ class _EventQueuePageState extends State { _addLog('开始事件队列测试', EventType.info); _addLog('代码开始执行', EventType.sync); Future(() => _addLog('Future() 执行', EventType.event)); - Future.delayed(const Duration(milliseconds: 500), - () => _addLog('Future.delayed 0.5秒后执行', EventType.event)); - Future.delayed(const Duration(seconds: 1), - () => _addLog('Future.delayed 1秒后执行', EventType.event)); - Future.delayed(const Duration(seconds: 2), - () => _addLog('Future.delayed 2秒后执行', EventType.event)); + Future.delayed( + const Duration(milliseconds: 500), + () => _addLog('Future.delayed 0.5秒后执行', EventType.event), + ); + Future.delayed( + const Duration(seconds: 1), + () => _addLog('Future.delayed 1秒后执行', EventType.event), + ); + Future.delayed( + const Duration(seconds: 2), + () => _addLog('Future.delayed 2秒后执行', EventType.event), + ); Timer.run(() => _addLog('Timer.run 执行', EventType.event)); _addLog('代码结束执行', EventType.sync); Future.delayed(const Duration(seconds: 3), () { @@ -84,10 +86,14 @@ class _EventQueuePageState extends State { _addLog('IO操作结果: $result', EventType.event); }); Future.wait([ - Future.delayed(const Duration(milliseconds: 800), - () => _addLog('并发IO操作1完成', EventType.event)), - Future.delayed(const Duration(milliseconds: 1200), - () => _addLog('并发IO操作2完成', EventType.event)), + Future.delayed( + const Duration(milliseconds: 800), + () => _addLog('并发IO操作1完成', EventType.event), + ), + Future.delayed( + const Duration(milliseconds: 1200), + () => _addLog('并发IO操作2完成', EventType.event), + ), ]).then((results) { _addLog('所有并发操作完成: $results', EventType.event); }); @@ -152,31 +158,30 @@ Timer.run(() { print('Timer事件队列任务执行'); });''', ), ), sections: [ - LearningObjectives(objectives: [ - '理解事件队列 (Event Queue) 的工作原理', - '掌握 Future、Future.delayed、Timer.run 的调度行为', - '区分同步代码与事件队列任务的执行顺序', - ]), - ConceptChips(concepts: [ - '事件队列', - 'Future', - 'Timer', - '异步调度', - '执行顺序', - ]), + LearningObjectives( + objectives: [ + '理解事件队列 (Event Queue) 的工作原理', + '掌握 Future、Future.delayed、Timer.run 的调度行为', + '区分同步代码与事件队列任务的执行顺序', + ], + ), + ConceptChips(concepts: ['事件队列', 'Future', 'Timer', '异步调度', '执行顺序']), CodeSnippetCard( title: '事件队列调度机制', - code: '// 事件队列任务总是在当前同步代码之后执行\n' + code: + '// 事件队列任务总是在当前同步代码之后执行\n' 'print("1: 同步");\n' 'Future(() => print("3: 事件任务"));\n' 'print("2: 同步");', explanation: 'Future() 将回调放入事件队列,在当前帧同步代码执行完毕后触发。', ), - CommonPitfalls(pitfalls: [ - 'Future.delayed 即使延迟为 0 也会进入事件队列,不会在当前帧执行', - '多个 Future.delayed 按延迟时间排序,但同延迟时按注册顺序执行', - 'Timer.run 等价于 Future(null),回调在事件队列中执行', - ]), + CommonPitfalls( + pitfalls: [ + 'Future.delayed 即使延迟为 0 也会进入事件队列,不会在当前帧执行', + '多个 Future.delayed 按延迟时间排序,但同延迟时按注册顺序执行', + 'Timer.run 等价于 Future(null),回调在事件队列中执行', + ], + ), ExerciseCard( task: '修改 _runBasicEventTest 中的延迟时间,观察执行顺序的变化。', hint: '尝试将 Future.delayed 的延迟时间设为相同值,观察按注册顺序执行的特点。', diff --git a/lib/modules/basic/microtask/pages/home_page.dart b/lib/modules/basic/microtask/pages/home_page.dart index da4863f..ba50b59 100644 --- a/lib/modules/basic/microtask/pages/home_page.dart +++ b/lib/modules/basic/microtask/pages/home_page.dart @@ -55,24 +55,29 @@ class HomePage extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Flutter 事件循环 (Event Loop) 的运作机制', - '掌握微任务队列 (Microtask Queue) 和事件队列 (Event Queue) 的区别', - '学会使用 scheduleMicrotask 和 Future 管理异步任务', - '理解 async/await 背后的微任务调度原理', - ]), - ConceptChips(concepts: [ - '事件循环', - 'Event Loop', - '微任务队列', - '事件队列', - 'scheduleMicrotask', - 'Future', - 'Zone', - ]), + LearningObjectives( + objectives: [ + '理解 Flutter 事件循环 (Event Loop) 的运作机制', + '掌握微任务队列 (Microtask Queue) 和事件队列 (Event Queue) 的区别', + '学会使用 scheduleMicrotask 和 Future 管理异步任务', + '理解 async/await 背后的微任务调度原理', + ], + ), + ConceptChips( + concepts: [ + '事件循环', + 'Event Loop', + '微任务队列', + '事件队列', + 'scheduleMicrotask', + 'Future', + 'Zone', + ], + ), CodeSnippetCard( title: '微任务 vs 事件任务', - code: 'scheduleMicrotask(() {\n' + code: + 'scheduleMicrotask(() {\n' ' print("微任务优先执行");\n' '});\n\n' 'Future(() {\n' @@ -80,11 +85,13 @@ class HomePage extends StatelessWidget { '});', explanation: '微任务队列优先级高于事件队列,每次事件循环先清空微任务再处理事件。', ), - CommonPitfalls(pitfalls: [ - '微任务会阻塞事件循环 — 过多微任务会导致 UI 卡顿', - 'Future.then 的回调是微任务,不是事件任务', - 'scheduleMicrotask 在同一个微任务中嵌套调用仍会优先于事件任务', - ]), + CommonPitfalls( + pitfalls: [ + '微任务会阻塞事件循环 — 过多微任务会导致 UI 卡顿', + 'Future.then 的回调是微任务,不是事件任务', + 'scheduleMicrotask 在同一个微任务中嵌套调用仍会优先于事件任务', + ], + ), ExerciseCard( task: '运行"基础微任务测试"观察微任务与事件任务的执行顺序,然后用代码验证你的猜测。', hint: '点击导航中的"微任务队列"页面,运行"基础微任务测试"按钮观察日志输出顺序。', @@ -122,11 +129,14 @@ class HomePage extends StatelessWidget { children: [ Icon(icon, color: Colors.grey[800]), const SizedBox(width: 8), - Text(title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - color: Colors.grey[800])), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.grey[800], + ), + ), ], ), const SizedBox(height: 8), @@ -134,8 +144,11 @@ class HomePage extends StatelessWidget { const Spacer(), Align( alignment: Alignment.bottomRight, - child: Icon(Icons.arrow_forward, - color: Colors.grey[800], size: 20), + child: Icon( + Icons.arrow_forward, + color: Colors.grey[800], + size: 20, + ), ), ], ), diff --git a/lib/modules/basic/microtask/pages/microtask_queue_page.dart b/lib/modules/basic/microtask/pages/microtask_queue_page.dart index 4482c71..dc0e55d 100644 --- a/lib/modules/basic/microtask/pages/microtask_queue_page.dart +++ b/lib/modules/basic/microtask/pages/microtask_queue_page.dart @@ -37,11 +37,7 @@ class _MicrotaskQueuePageState extends State { void _addLog(String message, EventType type) { if (!_isRunning) return; setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); + _logs.add(EventLog(message: message, type: type, id: _logs.length + 1)); }); scheduleMicrotask(_scrollToBottom); } @@ -58,12 +54,15 @@ class _MicrotaskQueuePageState extends State { _addLog('代码开始执行', EventType.sync); Future(() => _addLog('事件任务执行', EventType.event)); scheduleMicrotask( - () => _addLog('scheduleMicrotask 微任务1执行', EventType.microtask)); + () => _addLog('scheduleMicrotask 微任务1执行', EventType.microtask), + ); Future.microtask( - () => _addLog('Future.microtask 微任务2执行', EventType.microtask)); + () => _addLog('Future.microtask 微任务2执行', EventType.microtask), + ); Future(() => _addLog('另一个事件任务执行', EventType.event)); scheduleMicrotask( - () => _addLog('scheduleMicrotask 微任务3执行', EventType.microtask)); + () => _addLog('scheduleMicrotask 微任务3执行', EventType.microtask), + ); _addLog('代码结束执行', EventType.sync); Future.delayed(const Duration(seconds: 2), () { _addLog('微任务队列测试结束', EventType.info); @@ -82,7 +81,8 @@ class _MicrotaskQueuePageState extends State { .then((_) => _addLog('Future.then微任务2执行', EventType.microtask)); Future(() => _addLog('另一个事件任务执行', EventType.event)); scheduleMicrotask( - () => _addLog('scheduleMicrotask 微任务执行', EventType.microtask)); + () => _addLog('scheduleMicrotask 微任务执行', EventType.microtask), + ); _addLog('代码结束执行', EventType.sync); Future.delayed(const Duration(seconds: 2), () { _addLog('Future.then微任务测试结束', EventType.info); @@ -175,32 +175,39 @@ Future(() { print('事件任务执行'); }) ), ), sections: [ - LearningObjectives(objectives: [ - '理解微任务队列 (Microtask Queue) 的优先级特性', - '掌握 scheduleMicrotask 和 Future.microtask 的使用', - '理解 Future.then 回调作为微任务执行的机制', - '掌握嵌套微任务的行为特征', - ]), - ConceptChips(concepts: [ - '微任务队列', - 'scheduleMicrotask', - 'Future.microtask', - 'Future.then', - '优先级', - '嵌套微任务', - ]), + LearningObjectives( + objectives: [ + '理解微任务队列 (Microtask Queue) 的优先级特性', + '掌握 scheduleMicrotask 和 Future.microtask 的使用', + '理解 Future.then 回调作为微任务执行的机制', + '掌握嵌套微任务的行为特征', + ], + ), + ConceptChips( + concepts: [ + '微任务队列', + 'scheduleMicrotask', + 'Future.microtask', + 'Future.then', + '优先级', + '嵌套微任务', + ], + ), CodeSnippetCard( title: '微任务优先级示例', - code: 'scheduleMicrotask(() => print("1: 微任务"));\n' + code: + 'scheduleMicrotask(() => print("1: 微任务"));\n' 'Future(() => print("3: 事件任务"));\n' 'scheduleMicrotask(() => print("2: 微任务"));', explanation: '微任务始终在事件任务之前执行,即使微任务在事件任务之后注册。', ), - CommonPitfalls(pitfalls: [ - '微任务过多会导致事件队列饿死 — UI事件(触摸、渲染)也无法处理', - 'scheduleMicrotask 嵌套调用会递归清空微任务队列,可能导致长时间阻塞', - 'Future.then 是微任务不是事件任务 — 注意与 Future 本身的区别', - ]), + CommonPitfalls( + pitfalls: [ + '微任务过多会导致事件队列饿死 — UI事件(触摸、渲染)也无法处理', + 'scheduleMicrotask 嵌套调用会递归清空微任务队列,可能导致长时间阻塞', + 'Future.then 是微任务不是事件任务 — 注意与 Future 本身的区别', + ], + ), ExerciseCard( task: '运行"嵌套微任务测试"观察执行顺序,理解为什么嵌套微任务会先于事件任务执行。', hint: '微任务队列的"清空"策略是持续处理直到队列为空,新添加的微任务也会在当前批次处理。', diff --git a/lib/modules/basic/microtask/widgets/event_log_view.dart b/lib/modules/basic/microtask/widgets/event_log_view.dart index 3b3ee81..fb5f3de 100644 --- a/lib/modules/basic/microtask/widgets/event_log_view.dart +++ b/lib/modules/basic/microtask/widgets/event_log_view.dart @@ -24,10 +24,7 @@ class EventLogView extends StatelessWidget { ), child: logs.isEmpty ? const Center( - child: Text( - '点击上方按钮运行测试', - style: TextStyle(color: Colors.grey), - ), + child: Text('点击上方按钮运行测试', style: TextStyle(color: Colors.grey)), ) : ListView.builder( controller: scrollController, diff --git a/lib/modules/basic/tree_state/AI_ANALYSIS.md b/lib/modules/basic/tree_state/AI_ANALYSIS.md index ad0e9e3..4f4cfb9 100644 --- a/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/basic/tree_state/module_routes.dart b/lib/modules/basic/tree_state/module_routes.dart index 282846d..e7f7673 100644 --- a/lib/modules/basic/tree_state/module_routes.dart +++ b/lib/modules/basic/tree_state/module_routes.dart @@ -15,21 +15,18 @@ class TreeStateRoutes { static const String repaintBoundary = '/repaint_boundary_demo'; static List get routes => [ - GoRoute( - path: 'basic_widgets', - builder: (_, __) => const BasicWidgetsPage(), - ), - GoRoute( - path: 'state_lifecycle', - builder: (_, __) => const StateLifecyclePage(), - ), - GoRoute( - path: 'painter_demo', - builder: (_, __) => const PainterDemoPage(), - ), - GoRoute( - path: 'repaint_boundary_demo', - builder: (_, __) => const RepaintBoundaryDemoPage(), - ), - ]; + GoRoute( + path: 'basic_widgets', + builder: (_, __) => const BasicWidgetsPage(), + ), + GoRoute( + path: 'state_lifecycle', + builder: (_, __) => const StateLifecyclePage(), + ), + GoRoute(path: 'painter_demo', builder: (_, __) => const PainterDemoPage()), + GoRoute( + path: 'repaint_boundary_demo', + builder: (_, __) => const RepaintBoundaryDemoPage(), + ), + ]; } diff --git a/lib/modules/basic/tree_state/pages/basic_widgets_page.dart b/lib/modules/basic/tree_state/pages/basic_widgets_page.dart index 25c4931..647e644 100644 --- a/lib/modules/basic/tree_state/pages/basic_widgets_page.dart +++ b/lib/modules/basic/tree_state/pages/basic_widgets_page.dart @@ -111,17 +111,18 @@ class _StatefulBoxState extends State { child: Text( '父组件重建次数: $_counter', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), ), ), const SizedBox(height: 16), LayoutBuilder( builder: (context, constraints) { return Flex( - direction: - constraints.maxWidth > 480 ? Axis.horizontal : Axis.vertical, + direction: constraints.maxWidth > 480 + ? Axis.horizontal + : Axis.vertical, children: [ Expanded( child: Card( @@ -202,10 +203,9 @@ class _RebuildAwareBoxState extends State<_RebuildAwareBox> return Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 1 - _controller.value), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 1 - _controller.value), width: 2, ), borderRadius: BorderRadius.circular(8), @@ -244,8 +244,10 @@ class StatelessBox extends StatelessWidget { children: [ const Text('接收 count 参数', style: TextStyle(fontSize: 12)), const SizedBox(height: 4), - Text('count: $count', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + 'count: $count', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), ], ); } @@ -277,7 +279,8 @@ class _StatefulBoxState extends State { void didUpdateWidget(StatefulBox oldWidget) { super.didUpdateWidget(oldWidget); debugPrint( - '[StatefulBox] didUpdateWidget count=${oldWidget.count} → ${widget.count}'); + '[StatefulBox] didUpdateWidget count=${oldWidget.count} → ${widget.count}', + ); } @override @@ -288,8 +291,10 @@ class _StatefulBoxState extends State { children: [ const Text('持有 State 实例', style: TextStyle(fontSize: 12)), const SizedBox(height: 4), - Text('count: ${widget.count}', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + 'count: ${widget.count}', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), ], ); } diff --git a/lib/modules/basic/tree_state/pages/demo_home_page.dart b/lib/modules/basic/tree_state/pages/demo_home_page.dart index 8753f25..13e1ae5 100644 --- a/lib/modules/basic/tree_state/pages/demo_home_page.dart +++ b/lib/modules/basic/tree_state/pages/demo_home_page.dart @@ -75,7 +75,7 @@ class DemoHomePage extends StatelessWidget { '生命周期', 'setState', 'RepaintBoundary', - 'CustomPainter' + 'CustomPainter', ], ), CodeSnippetCard( diff --git a/lib/modules/basic/tree_state/pages/painter_demo_page.dart b/lib/modules/basic/tree_state/pages/painter_demo_page.dart index 3ca72dc..d7e7859 100644 --- a/lib/modules/basic/tree_state/pages/painter_demo_page.dart +++ b/lib/modules/basic/tree_state/pages/painter_demo_page.dart @@ -43,7 +43,8 @@ class _PainterDemoPageState extends State { _radius = value; }); _logs.add( - 'Slider → radius=${_radius.toStringAsFixed(0)}'); + 'Slider → radius=${_radius.toStringAsFixed(0)}', + ); }, ), ), @@ -72,14 +73,22 @@ class _PainterDemoPageState extends State { child: ListView( children: _logs.isEmpty ? [ - const Text('拖动 Slider 观察 paint 和 shouldRepaint 日志', - style: TextStyle(color: Colors.grey, fontSize: 12)) + const Text( + '拖动 Slider 观察 paint 和 shouldRepaint 日志', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), ] : _logs - .map((l) => Text(l, - style: const TextStyle( - fontSize: 11, fontFamily: 'monospace'))) - .toList(), + .map( + (l) => Text( + l, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ) + .toList(), ), ), ], @@ -99,7 +108,7 @@ class _PainterDemoPageState extends State { 'CustomPainter', 'shouldRepaint', 'Canvas', - 'paint' + 'paint', ], ), CodeSnippetCard( @@ -158,9 +167,15 @@ class PainterDemoPainter extends CustomPainter { ..color = Colors.grey ..strokeWidth = 1; canvas.drawLine( - Offset(center.dx, 0), Offset(center.dx, size.height), axisPaint); + Offset(center.dx, 0), + Offset(center.dx, size.height), + axisPaint, + ); canvas.drawLine( - Offset(0, center.dy), Offset(size.width, center.dy), axisPaint); + Offset(0, center.dy), + Offset(size.width, center.dy), + axisPaint, + ); final fillPaint = Paint() ..color = Colors.blueAccent.withValues(alpha: 0.3) diff --git a/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart b/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart index 2942725..6d36201 100644 --- a/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart +++ b/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart @@ -91,17 +91,25 @@ class _RepaintBoundaryDemoPageState extends State { scrollDirection: Axis.horizontal, children: _logs.isEmpty ? [ - const Text('点击按钮观察 paint 日志', - style: TextStyle(color: Colors.grey, fontSize: 12)) + const Text( + '点击按钮观察 paint 日志', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), ] : _logs - .map((l) => Padding( + .map( + (l) => Padding( padding: const EdgeInsets.only(right: 8), - child: Text(l, - style: const TextStyle( - fontSize: 11, fontFamily: 'monospace')), - )) - .toList(), + child: Text( + l, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + ) + .toList(), ), ), ], @@ -121,7 +129,7 @@ class _RepaintBoundaryDemoPageState extends State { 'CustomPainter', 'shouldRepaint', '局部重绘', - 'RenderObject' + 'RenderObject', ], ), CodeSnippetCard( @@ -157,8 +165,10 @@ class _RepaintBoundaryDemoPageState extends State { Widget _buildSection({required String label, required Widget child}) { return Column( children: [ - Text(label, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + Text( + label, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), const SizedBox(height: 4), Expanded(child: child), ], diff --git a/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart b/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart index 3758f31..84af917 100644 --- a/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart +++ b/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart @@ -106,14 +106,22 @@ class _StateLifecyclePageState extends State { child: ListView( children: _logs.isEmpty ? [ - const Text('操作按钮观察生命周期日志', - style: TextStyle(color: Colors.grey)) + const Text( + '操作按钮观察生命周期日志', + style: TextStyle(color: Colors.grey), + ), ] : _logs - .map((l) => Text(l, - style: const TextStyle( - fontSize: 12, fontFamily: 'monospace'))) - .toList(), + .map( + (l) => Text( + l, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ) + .toList(), ), ), ), @@ -137,7 +145,7 @@ class _StateLifecyclePageState extends State { 'setState', 'didUpdateWidget', 'deactivate', - 'dispose' + 'dispose', ], ), CodeSnippetCard( @@ -182,9 +190,7 @@ class LifecycleChildPage extends StatelessWidget { debugPrint('[LifecycleChildPage] build'); return Scaffold( appBar: AppBar(title: const Text('Child Page')), - body: const Center( - child: Text('观察父页面 push/pop 时的生命周期日志'), - ), + body: const Center(child: Text('观察父页面 push/pop 时的生命周期日志')), ); } } diff --git a/lib/modules/platform/AI_ANALYSIS.md b/lib/modules/platform/AI_ANALYSIS.md index dd561a9..33c8dcc 100644 --- a/lib/modules/platform/AI_ANALYSIS.md +++ b/lib/modules/platform/AI_ANALYSIS.md @@ -10,7 +10,8 @@ }, "entrypoints": [ "dio_interceptor", - "usb_detector" + "usb_detector", + "online_video_player" ], "owns": [ "network_platform" @@ -19,18 +20,21 @@ "dio", "usb_serial", "device_info_plus", + "media_kit", + "media_kit_video", "flutter_study_learning" ], "children": [ "dio_interceptor/AI_ANALYSIS.md", - "usb_detector/AI_ANALYSIS.md" + "usb_detector/AI_ANALYSIS.md", + "online_video_player/AI_ANALYSIS.md" ], "contracts": { "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 9321306..0c7ae94 100644 --- a/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/platform/dio_interceptor/mock_server/mock_server.dart b/lib/modules/platform/dio_interceptor/mock_server/mock_server.dart index 4870c61..4f42cc3 100644 --- a/lib/modules/platform/dio_interceptor/mock_server/mock_server.dart +++ b/lib/modules/platform/dio_interceptor/mock_server/mock_server.dart @@ -44,8 +44,9 @@ class MockServer { 'title': '文章标题 $i', 'content': '这是文章 $i 的内容,用于测试拦截器功能。', 'author': i % 2 == 0 ? 'admin' : 'user', - 'createdAt': - DateTime.now().subtract(Duration(days: 20 - i)).toIso8601String(), + 'createdAt': DateTime.now() + .subtract(Duration(days: 20 - i)) + .toIso8601String(), }); } } @@ -83,9 +84,13 @@ class MockServer { // 添加CORS头 request.response.headers.add('Access-Control-Allow-Origin', '*'); request.response.headers.add( - 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - request.response.headers.add('Access-Control-Allow-Headers', - 'Origin, Content-Type, X-Auth-Token, Authorization'); + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, OPTIONS', + ); + request.response.headers.add( + 'Access-Control-Allow-Headers', + 'Origin, Content-Type, X-Auth-Token, Authorization', + ); // 处理预检请求 if (request.method == 'OPTIONS') { @@ -101,10 +106,9 @@ class MockServer { if (Random().nextDouble() < failureRate) { request.response.statusCode = HttpStatus.internalServerError; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '模拟服务器随机错误,请重试', - })); + request.response.write( + json.encode({'success': false, 'message': '模拟服务器随机错误,请重试'}), + ); await request.response.close(); if (kDebugMode) { @@ -132,20 +136,21 @@ class MockServer { // 404 Not Found request.response.statusCode = HttpStatus.notFound; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '404 Not Found: ${request.uri.path}', - })); + request.response.write( + json.encode({ + 'success': false, + 'message': '404 Not Found: ${request.uri.path}', + }), + ); break; } } catch (e) { // 处理过程中的错误 request.response.statusCode = HttpStatus.internalServerError; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '服务器内部错误: $e', - })); + request.response.write( + json.encode({'success': false, 'message': '服务器内部错误: $e'}), + ); if (kDebugMode) { print('处理请求时出错: $e'); @@ -161,10 +166,9 @@ class MockServer { if (request.method != 'POST') { request.response.statusCode = HttpStatus.methodNotAllowed; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '方法不允许', - })); + request.response.write( + json.encode({'success': false, 'message': '方法不允许'}), + ); return; } @@ -177,10 +181,9 @@ class MockServer { !data.containsKey('password')) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '缺少用户名或密码', - })); + request.response.write( + json.encode({'success': false, 'message': '缺少用户名或密码'}), + ); return; } @@ -191,10 +194,9 @@ class MockServer { if (!_users.containsKey(username) || _users[username] != password) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '用户名或密码错误', - })); + request.response.write( + json.encode({'success': false, 'message': '用户名或密码错误'}), + ); return; } @@ -214,15 +216,17 @@ class MockServer { request.response.statusCode = HttpStatus.ok; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': true, - 'data': { - 'token': token, - 'refreshToken': refreshToken, - 'expiresIn': expiresIn, - 'username': username, - }, - })); + request.response.write( + json.encode({ + 'success': true, + 'data': { + 'token': token, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'username': username, + }, + }), + ); if (kDebugMode) { print('用户 $username 登录成功'); @@ -230,10 +234,9 @@ class MockServer { } catch (e) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的请求数据: $e', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的请求数据: $e'}), + ); } } @@ -242,10 +245,9 @@ class MockServer { if (request.method != 'POST') { request.response.statusCode = HttpStatus.methodNotAllowed; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '方法不允许', - })); + request.response.write( + json.encode({'success': false, 'message': '方法不允许'}), + ); return; } @@ -256,10 +258,9 @@ class MockServer { if (data is! Map || !data.containsKey('refreshToken')) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '缺少刷新令牌', - })); + request.response.write( + json.encode({'success': false, 'message': '缺少刷新令牌'}), + ); return; } @@ -277,10 +278,9 @@ class MockServer { if (matchedToken == null) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的刷新令牌', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的刷新令牌'}), + ); return; } @@ -307,15 +307,17 @@ class MockServer { request.response.statusCode = HttpStatus.ok; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': true, - 'data': { - 'token': newToken, - 'refreshToken': newRefreshToken, - 'expiresIn': expiresIn, - 'username': username, - }, - })); + request.response.write( + json.encode({ + 'success': true, + 'data': { + 'token': newToken, + 'refreshToken': newRefreshToken, + 'expiresIn': expiresIn, + 'username': username, + }, + }), + ); if (kDebugMode) { print('用户 $username 刷新令牌成功'); @@ -323,10 +325,9 @@ class MockServer { } catch (e) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的请求数据: $e', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的请求数据: $e'}), + ); } } @@ -337,10 +338,9 @@ class MockServer { if (authHeader == null || !authHeader.startsWith('Bearer ')) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '未授权,缺少有效的认证信息', - })); + request.response.write( + json.encode({'success': false, 'message': '未授权,缺少有效的认证信息'}), + ); return; } @@ -349,10 +349,9 @@ class MockServer { if (!_tokens.containsKey(token)) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的认证令牌', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的认证令牌'}), + ); return; } @@ -362,10 +361,9 @@ class MockServer { if (DateTime.now().isAfter(expiresAt)) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '认证令牌已过期', - })); + request.response.write( + json.encode({'success': false, 'message': '认证令牌已过期'}), + ); return; } @@ -378,22 +376,26 @@ class MockServer { final startIndex = (page - 1) * pageSize; final endIndex = startIndex + pageSize; final pagedArticles = _articles.length > startIndex - ? _articles.sublist(startIndex, - endIndex > _articles.length ? _articles.length : endIndex) + ? _articles.sublist( + startIndex, + endIndex > _articles.length ? _articles.length : endIndex, + ) : []; request.response.statusCode = HttpStatus.ok; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': true, - 'data': { - 'articles': pagedArticles, - 'total': _articles.length, - 'page': page, - 'pageSize': pageSize, - 'totalPages': (_articles.length / pageSize).ceil(), - }, - })); + request.response.write( + json.encode({ + 'success': true, + 'data': { + 'articles': pagedArticles, + 'total': _articles.length, + 'page': page, + 'pageSize': pageSize, + 'totalPages': (_articles.length / pageSize).ceil(), + }, + }), + ); } /// 处理创建文章请求 @@ -401,10 +403,9 @@ class MockServer { if (request.method != 'POST') { request.response.statusCode = HttpStatus.methodNotAllowed; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '方法不允许', - })); + request.response.write( + json.encode({'success': false, 'message': '方法不允许'}), + ); return; } @@ -413,10 +414,9 @@ class MockServer { if (authHeader == null || !authHeader.startsWith('Bearer ')) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '未授权,缺少有效的认证信息', - })); + request.response.write( + json.encode({'success': false, 'message': '未授权,缺少有效的认证信息'}), + ); return; } @@ -425,10 +425,9 @@ class MockServer { if (!_tokens.containsKey(token)) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的认证令牌', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的认证令牌'}), + ); return; } @@ -438,10 +437,9 @@ class MockServer { if (DateTime.now().isAfter(expiresAt)) { request.response.statusCode = HttpStatus.unauthorized; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '认证令牌已过期', - })); + request.response.write( + json.encode({'success': false, 'message': '认证令牌已过期'}), + ); return; } @@ -454,10 +452,9 @@ class MockServer { !data.containsKey('content')) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '缺少标题或内容', - })); + request.response.write( + json.encode({'success': false, 'message': '缺少标题或内容'}), + ); return; } @@ -478,10 +475,9 @@ class MockServer { request.response.statusCode = HttpStatus.created; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': true, - 'data': newArticle, - })); + request.response.write( + json.encode({'success': true, 'data': newArticle}), + ); if (kDebugMode) { print('用户 $username 创建了新文章: $title'); @@ -489,10 +485,9 @@ class MockServer { } catch (e) { request.response.statusCode = HttpStatus.badRequest; request.response.headers.contentType = ContentType.json; - request.response.write(json.encode({ - 'success': false, - 'message': '无效的请求数据: $e', - })); + request.response.write( + json.encode({'success': false, 'message': '无效的请求数据: $e'}), + ); } } diff --git a/lib/modules/platform/dio_interceptor/module_routes.dart b/lib/modules/platform/dio_interceptor/module_routes.dart index 74f8f8a..9919d57 100644 --- a/lib/modules/platform/dio_interceptor/module_routes.dart +++ b/lib/modules/platform/dio_interceptor/module_routes.dart @@ -9,9 +9,6 @@ class InterceptorTestRoutes { static const String login = '/login'; static List get routes => [ - GoRoute( - path: 'login', - builder: (_, __) => const LoginPage(), - ), - ]; + GoRoute(path: 'login', builder: (_, __) => const LoginPage()), + ]; } diff --git a/lib/modules/platform/dio_interceptor/network/api/api_service.dart b/lib/modules/platform/dio_interceptor/network/api/api_service.dart index e3ae8aa..8bef6e2 100644 --- a/lib/modules/platform/dio_interceptor/network/api/api_service.dart +++ b/lib/modules/platform/dio_interceptor/network/api/api_service.dart @@ -15,10 +15,7 @@ class ApiService { try { final response = await _httpClient.post( '/api/login', - data: { - 'username': username, - 'password': password, - }, + data: {'username': username, 'password': password}, ); return response.data; @@ -32,9 +29,7 @@ class ApiService { try { final response = await _httpClient.post( '/api/refresh-token', - data: { - 'refreshToken': refreshToken, - }, + data: {'refreshToken': refreshToken}, ); return response.data; @@ -44,15 +39,14 @@ class ApiService { } /// 获取文章列表 - Future> getArticles( - {int page = 1, int pageSize = 10}) async { + Future> getArticles({ + int page = 1, + int pageSize = 10, + }) async { try { final response = await _httpClient.get( '/api/articles', - queryParameters: { - 'page': page, - 'pageSize': pageSize, - }, + queryParameters: {'page': page, 'pageSize': pageSize}, ); return response.data; @@ -63,14 +57,13 @@ class ApiService { /// 创建文章 Future> createArticle( - String title, String content) async { + String title, + String content, + ) async { try { final response = await _httpClient.post( '/api/articles/create', - data: { - 'title': title, - 'content': content, - }, + data: {'title': title, 'content': content}, ); return response.data; diff --git a/lib/modules/platform/dio_interceptor/network/interceptor/log_interceptor.dart b/lib/modules/platform/dio_interceptor/network/interceptor/log_interceptor.dart index efc2345..862a6ca 100644 --- a/lib/modules/platform/dio_interceptor/network/interceptor/log_interceptor.dart +++ b/lib/modules/platform/dio_interceptor/network/interceptor/log_interceptor.dart @@ -15,7 +15,8 @@ class LoggingInterceptor extends Interceptor { if (kDebugMode) { debugPrint( - '┌────────────────────────────────────────────────────────────────────────────────────────────────────'); + '┌────────────────────────────────────────────────────────────────────────────────────────────────────', + ); debugPrint('│ 请求 [${options.method}] → ${options.uri}'); if (options.headers.isNotEmpty) { @@ -37,7 +38,8 @@ class LoggingInterceptor extends Interceptor { } debugPrint( - '└────────────────────────────────────────────────────────────────────────────────────────────────────'); + '└────────────────────────────────────────────────────────────────────────────────────────────────────', + ); } handler.next(options); @@ -56,9 +58,11 @@ class LoggingInterceptor extends Interceptor { if (kDebugMode) { debugPrint( - '┌────────────────────────────────────────────────────────────────────────────────────────────────────'); + '┌────────────────────────────────────────────────────────────────────────────────────────────────────', + ); debugPrint( - '│ 响应 [${response.statusCode}] ← ${response.requestOptions.uri}'); + '│ 响应 [${response.statusCode}] ← ${response.requestOptions.uri}', + ); if (duration != null) { debugPrint('│ 耗时: ${duration}ms'); @@ -75,7 +79,8 @@ class LoggingInterceptor extends Interceptor { printWrapped(response.data.toString()); debugPrint( - '└────────────────────────────────────────────────────────────────────────────────────────────────────'); + '└────────────────────────────────────────────────────────────────────────────────────────────────────', + ); } handler.next(response); @@ -94,9 +99,11 @@ class LoggingInterceptor extends Interceptor { if (kDebugMode) { debugPrint( - '┌────────────────────────────────────────────────────────────────────────────────────────────────────'); + '┌────────────────────────────────────────────────────────────────────────────────────────────────────', + ); debugPrint( - '│ 错误 [${err.response?.statusCode ?? "未知状态码"}] ← ${err.requestOptions.uri}'); + '│ 错误 [${err.response?.statusCode ?? "未知状态码"}] ← ${err.requestOptions.uri}', + ); debugPrint('│ 类型: ${err.type}'); if (duration != null) { @@ -111,7 +118,8 @@ class LoggingInterceptor extends Interceptor { } debugPrint( - '└────────────────────────────────────────────────────────────────────────────────────────────────────'); + '└────────────────────────────────────────────────────────────────────────────────────────────────────', + ); } handler.next(err); diff --git a/lib/modules/platform/dio_interceptor/network/interceptor/retry_interceptor.dart b/lib/modules/platform/dio_interceptor/network/interceptor/retry_interceptor.dart index 80e5634..47a54b7 100644 --- a/lib/modules/platform/dio_interceptor/network/interceptor/retry_interceptor.dart +++ b/lib/modules/platform/dio_interceptor/network/interceptor/retry_interceptor.dart @@ -20,12 +20,13 @@ class RetryInterceptor extends Interceptor { this.maxRetries = 3, this.retryInterval = 1000, Set? retryableErrors, - }) : retryableErrors = retryableErrors ?? - { - DioExceptionType.connectionTimeout, - DioExceptionType.receiveTimeout, - DioExceptionType.connectionError, - }; + }) : retryableErrors = + retryableErrors ?? + { + DioExceptionType.connectionTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.connectionError, + }; @override void onError(DioException err, ErrorInterceptorHandler handler) async { @@ -36,7 +37,8 @@ class RetryInterceptor extends Interceptor { final int currentRetryCount = _retryCountMap[requestId] ?? 0; // 判断是否满足重试条件 - bool shouldRetry = currentRetryCount < maxRetries && // 未超过最大重试次数 + bool shouldRetry = + currentRetryCount < maxRetries && // 未超过最大重试次数 _shouldRetryError(err) && // 是可重试的错误类型 _isIdempotentRequest(err.requestOptions); // 是幂等请求 @@ -46,7 +48,8 @@ class RetryInterceptor extends Interceptor { if (kDebugMode) { print( - 'RetryInterceptor - 将在${retryInterval}ms后进行第${currentRetryCount + 1}次重试'); + 'RetryInterceptor - 将在${retryInterval}ms后进行第${currentRetryCount + 1}次重试', + ); print('请求: ${err.requestOptions.uri}'); } @@ -126,8 +129,13 @@ class RetryInterceptor extends Interceptor { /// 判断请求是否是幂等的(可以安全重试) bool _isIdempotentRequest(RequestOptions options) { // GET、HEAD、OPTIONS 请求通常是幂等的 - return ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'] - .contains(options.method.toUpperCase()); + return [ + 'GET', + 'HEAD', + 'OPTIONS', + 'PUT', + 'DELETE', + ].contains(options.method.toUpperCase()); // 注意:POST请求通常不是幂等的,但某些特定API可能设计为幂等,这需要具体情况具体分析 } diff --git a/lib/modules/platform/dio_interceptor/pages/home_page.dart b/lib/modules/platform/dio_interceptor/pages/home_page.dart index af7ae20..df7b1b9 100644 --- a/lib/modules/platform/dio_interceptor/pages/home_page.dart +++ b/lib/modules/platform/dio_interceptor/pages/home_page.dart @@ -44,8 +44,9 @@ class _HomePageState extends State { final result = await _apiService.getArticles(page: _currentPage); if (result['success'] == true && result['data'] != null) { final articlesData = result['data']['articles'] as List; - final articles = - articlesData.map((json) => Article.fromJson(json)).toList(); + final articles = articlesData + .map((json) => Article.fromJson(json)) + .toList(); setState(() { _articles = articles; _totalPages = result['data']['totalPages'] as int; @@ -91,9 +92,9 @@ class _HomePageState extends State { void _logout() { AuthInterceptor.clearToken(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已退出登录')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已退出登录'))); _refreshArticles(); } @@ -117,10 +118,13 @@ class _HomePageState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Text(isLoggedIn ? '已登录' : '未登录', - style: TextStyle( - fontSize: 12, - color: isLoggedIn ? Colors.green : Colors.grey)), + Text( + isLoggedIn ? '已登录' : '未登录', + style: TextStyle( + fontSize: 12, + color: isLoggedIn ? Colors.green : Colors.grey, + ), + ), const SizedBox(width: 8), IconButton( icon: Icon(isLoggedIn ? Icons.logout : Icons.login, size: 20), @@ -134,23 +138,21 @@ class _HomePageState extends State { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Dio 拦截器的工作原理和链路机制', - '掌握 Auth 拦截器实现 Token 自动注入', - '理解 Error 拦截器统一错误处理', - '掌握 Retry 拦截器实现请求重试', - ]), - ConceptChips(concepts: [ - 'Dio', - '拦截器', - 'Token', - '重试机制', - '错误处理', - 'Mock Server', - ]), + LearningObjectives( + objectives: [ + '理解 Dio 拦截器的工作原理和链路机制', + '掌握 Auth 拦截器实现 Token 自动注入', + '理解 Error 拦截器统一错误处理', + '掌握 Retry 拦截器实现请求重试', + ], + ), + ConceptChips( + concepts: ['Dio', '拦截器', 'Token', '重试机制', '错误处理', 'Mock Server'], + ), CodeSnippetCard( title: 'Dio 拦截器链路', - code: 'final dio = Dio(BaseOptions(baseUrl: url));\n' + code: + 'final dio = Dio(BaseOptions(baseUrl: url));\n' 'dio.interceptors.addAll([\n' ' AuthInterceptor(),\n' ' LoggingInterceptor(),\n' @@ -159,11 +161,13 @@ class _HomePageState extends State { ']);', explanation: '拦截器按添加顺序组成链路,请求从 Auth → Logging → Retry → Error 依次经过。', ), - CommonPitfalls(pitfalls: [ - '拦截器顺序很重要 — Auth 应放在首位确保后续拦截器也能使用 Token', - 'Retry 拦截器需避免死循环 — 设置最大重试次数和指数退避策略', - 'Error 拦截器不要吞掉异常 — 统一处理后应继续抛出或返回友好提示', - ]), + CommonPitfalls( + pitfalls: [ + '拦截器顺序很重要 — Auth 应放在首位确保后续拦截器也能使用 Token', + 'Retry 拦截器需避免死循环 — 设置最大重试次数和指数退避策略', + 'Error 拦截器不要吞掉异常 — 统一处理后应继续抛出或返回友好提示', + ], + ), ExerciseCard( task: '在 RetryInterceptor 中添加"登录过期"检测,当响应为 401 时自动跳转登录页。', hint: @@ -182,11 +186,15 @@ class _HomePageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('错误: $_errorMessage', - style: const TextStyle(color: Colors.red)), + Text( + '错误: $_errorMessage', + style: const TextStyle(color: Colors.red), + ), const SizedBox(height: 16), ElevatedButton( - onPressed: _refreshArticles, child: const Text('重试')), + onPressed: _refreshArticles, + child: const Text('重试'), + ), ], ), ); @@ -265,22 +273,27 @@ class _HomePageState extends State { children: [ TextField( controller: titleController, - decoration: - const InputDecoration(labelText: '标题', hintText: '请输入文章标题'), + decoration: const InputDecoration( + labelText: '标题', + hintText: '请输入文章标题', + ), ), const SizedBox(height: 16), TextField( controller: contentController, - decoration: - const InputDecoration(labelText: '内容', hintText: '请输入文章内容'), + decoration: const InputDecoration( + labelText: '内容', + hintText: '请输入文章内容', + ), maxLines: 3, ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('取消')), + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消'), + ), TextButton( onPressed: () async { final title = titleController.text.trim(); diff --git a/lib/modules/platform/dio_interceptor/pages/login_page.dart b/lib/modules/platform/dio_interceptor/pages/login_page.dart index 33d3a78..e056f4c 100644 --- a/lib/modules/platform/dio_interceptor/pages/login_page.dart +++ b/lib/modules/platform/dio_interceptor/pages/login_page.dart @@ -74,8 +74,10 @@ class _LoginPageState extends State { borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.red.shade200), ), - child: Text(_errorMessage!, - style: const TextStyle(color: Colors.red)), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), ), TextFormField( controller: _usernameController, @@ -126,14 +128,14 @@ class _LoginPageState extends State { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Token 认证流程及拦截器自动注入机制', - '掌握 AuthInterceptor 的实现与使用', - ]), + LearningObjectives( + objectives: ['理解 Token 认证流程及拦截器自动注入机制', '掌握 AuthInterceptor 的实现与使用'], + ), ConceptChips(concepts: ['Token', '认证', '登录', 'AuthInterceptor']), CodeSnippetCard( title: 'AuthInterceptor 实现', - code: 'class AuthInterceptor extends Interceptor {\n' + code: + 'class AuthInterceptor extends Interceptor {\n' ' @override\n' ' void onRequest(options, handler) {\n' ' final token = getToken();\n' diff --git a/lib/modules/platform/online_video_player/AI_ANALYSIS.md b/lib/modules/platform/online_video_player/AI_ANALYSIS.md new file mode 100644 index 0000000..be43d7d --- /dev/null +++ b/lib/modules/platform/online_video_player/AI_ANALYSIS.md @@ -0,0 +1,44 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "module_contract", + "node": { + "id": "main_app.modules.platform.online_video_player", + "kind": "learning_module", + "package": "main_app", + "path": "lib/modules/platform/online_video_player", + "status": "ready" + }, + "route": "/online-video-player", + "category": "platform", + "entrypoints": [ + "module_entry.dart", + "module_root.dart", + "widgets", + "state" + ], + "owns": [ + "module_entry", + "module_ui", + "module_docs" + ], + "depends": [ + "flutter_study_learning", + "media_kit", + "media_kit_video", + "module_registry" + ], + "children": [], + "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", + "contracts": { + "no_natural_language": true, + "index_only": true, + "max_index_depth": 2, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", + "update_required_on_file_change": true, + "import_direction_enforced": true + }, + "validation": [ + "flutter analyze" + ] +} diff --git a/lib/modules/platform/online_video_player/module_entry.dart b/lib/modules/platform/online_video_player/module_entry.dart new file mode 100644 index 0000000..7ac5ebd --- /dev/null +++ b/lib/modules/platform/online_video_player/module_entry.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'module_root.dart'; + +class OnlineVideoPlayerEntry extends StatelessWidget { + const OnlineVideoPlayerEntry({super.key}); + + @override + Widget build(BuildContext context) { + return const MyHomePage(title: '在线视频播放'); + } +} diff --git a/lib/modules/platform/online_video_player/module_root.dart b/lib/modules/platform/online_video_player/module_root.dart new file mode 100644 index 0000000..239f0b0 --- /dev/null +++ b/lib/modules/platform/online_video_player/module_root.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'state/media_kit_player_adapter.dart'; +import 'widgets/video_player_controls.dart'; + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title, this.adapter}); + + final String title; + final VideoPlayerAdapter? adapter; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + late final VideoPlayerAdapter _adapter = + widget.adapter ?? MediaKitPlayerAdapter(); + + @override + void initState() { + super.initState(); + _adapter.openAndPlay(); + } + + @override + void dispose() { + _adapter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: widget.title, + floatingActionButton: FloatingActionButton( + tooltip: '重新加载视频', + onPressed: _adapter.openAndPlay, + child: const Icon(Icons.refresh), + ), + interactiveDemo: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ColoredBox( + color: Colors.black, + child: ValueListenableBuilder( + valueListenable: _adapter.uiState, + builder: (context, state, child) { + if (state == PlayerUiState.error) { + return const _VideoPlaceholder( + key: Key('video-error-placeholder'), + icon: Icons.error_outline, + message: '视频加载失败,请检查网络后重试', + ); + } + if (state == PlayerUiState.idle) { + return const _VideoPlaceholder( + icon: Icons.ondemand_video, + message: '等待加载在线视频', + ); + } + final controller = _adapter.videoController; + if (controller == null) { + return const _VideoPlaceholder( + icon: Icons.videocam_off_outlined, + message: '视频渲染器不可用', + ); + } + return Stack( + fit: StackFit.expand, + children: [ + Video( + controller: controller, + controls: NoVideoControls, + ), + if (state == PlayerUiState.loading) + const ColoredBox( + color: Color(0x66000000), + child: Center(child: CircularProgressIndicator()), + ), + ], + ); + }, + ), + ), + ), + ), + const SizedBox(height: 12), + VideoPlayerControls(adapter: _adapter), + const SizedBox(height: 8), + const SelectableText( + '示例地址:$sampleStreamUrl', + style: TextStyle(fontSize: 12), + ), + ], + ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 media_kit、VideoController 与原生 libmpv 的职责边界', + '掌握在线媒体打开、播放、暂停、跳转、音量和倍速控制', + '正确管理 Player、流订阅和 ValueNotifier 的生命周期', + ], + ), + ConceptChips( + concepts: [ + 'media_kit', + 'libmpv', + 'HTTP 流', + '播放控制', + '倍速', + 'Player 生命周期', + ], + ), + CodeSnippetCard( + title: '打开并播放在线媒体', + code: + "final player = Player();\n" + "final controller = VideoController(player);\n" + "await player.open(\n" + " Media('https://example.com/video.mp4'),\n" + " play: true,\n" + ");", + explanation: 'Player 负责媒体状态,VideoController 将视频画面连接到 Flutter Widget。', + ), + CommonPitfalls( + pitfalls: [ + 'macOS 沙箱默认禁止外部网络访问,需要同时配置 DebugProfile 与 Release 的 network.client 权限', + 'media_kit 必须在 runApp 前完成全局初始化,否则原生后端可能无法正确加载', + 'Player、流订阅和监听器必须在页面销毁时释放,避免原生资源与回调泄漏', + ], + ), + ExerciseCard( + task: '增加一个 URL 输入框,让使用者加载自己的 HTTPS 视频,并保留最近一次成功播放的地址。', + hint: '先校验 Uri 的 scheme 与 host,再把媒体地址作为适配器 open 方法的参数。', + ), + ], + ); + } +} + +class _VideoPlaceholder extends StatelessWidget { + const _VideoPlaceholder({ + super.key, + required this.icon, + required this.message, + }); + + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white70, size: 52), + const SizedBox(height: 12), + Text(message, style: const TextStyle(color: Colors.white)), + ], + ), + ); + } +} diff --git a/lib/modules/platform/online_video_player/state/media_kit_player_adapter.dart b/lib/modules/platform/online_video_player/state/media_kit_player_adapter.dart new file mode 100644 index 0000000..b787ee4 --- /dev/null +++ b/lib/modules/platform/online_video_player/state/media_kit_player_adapter.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +const sampleStreamUrl = 'https://media.w3.org/2010/05/sintel/trailer.mp4'; + +enum PlayerUiState { idle, loading, playing, paused, error } + +abstract interface class VideoPlayerAdapter { + ValueNotifier get uiState; + ValueNotifier get position; + ValueNotifier get duration; + ValueNotifier get volume; + ValueNotifier get rate; + VideoController? get videoController; + + Future openAndPlay(); + Future play(); + Future pause(); + Future togglePlayPause(); + Future seek(Duration value); + Future setVolume(double value); + Future setRate(double value); + void dispose(); +} + +class MediaKitPlayerAdapter implements VideoPlayerAdapter { + MediaKitPlayerAdapter({Player? player}) : _player = player ?? Player() { + videoController = VideoController(_player); + _subscriptions.addAll([ + _player.stream.position.listen((value) => position.value = value), + _player.stream.duration.listen((value) => duration.value = value), + _player.stream.playing.listen((isPlaying) { + if (uiState.value == PlayerUiState.loading || + uiState.value == PlayerUiState.error) { + if (!isPlaying) return; + } + uiState.value = isPlaying + ? PlayerUiState.playing + : PlayerUiState.paused; + }), + _player.stream.error.listen((_) => uiState.value = PlayerUiState.error), + ]); + } + + final Player _player; + final List> _subscriptions = []; + + @override + late final VideoController videoController; + + @override + final ValueNotifier uiState = ValueNotifier( + PlayerUiState.idle, + ); + + @override + final ValueNotifier position = ValueNotifier(Duration.zero); + + @override + final ValueNotifier duration = ValueNotifier(Duration.zero); + + @override + final ValueNotifier volume = ValueNotifier(1); + + @override + final ValueNotifier rate = ValueNotifier(1); + + @override + Future openAndPlay() async { + uiState.value = PlayerUiState.loading; + position.value = Duration.zero; + try { + await _player.open(Media(sampleStreamUrl), play: true); + } on Object { + uiState.value = PlayerUiState.error; + } + } + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future togglePlayPause() => _player.playOrPause(); + + @override + Future seek(Duration value) => _player.seek(value); + + @override + Future setVolume(double value) async { + final normalized = value.clamp(0.0, 1.0); + volume.value = normalized; + await _player.setVolume(normalized * 100); + } + + @override + Future setRate(double value) async { + rate.value = value; + await _player.setRate(value); + } + + @override + void dispose() { + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + unawaited(_player.dispose()); + uiState.dispose(); + position.dispose(); + duration.dispose(); + volume.dispose(); + rate.dispose(); + } +} diff --git a/lib/modules/platform/online_video_player/widgets/video_player_controls.dart b/lib/modules/platform/online_video_player/widgets/video_player_controls.dart new file mode 100644 index 0000000..b10dab7 --- /dev/null +++ b/lib/modules/platform/online_video_player/widgets/video_player_controls.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../state/media_kit_player_adapter.dart'; + +class VideoPlayerControls extends StatelessWidget { + const VideoPlayerControls({super.key, required this.adapter}); + + final VideoPlayerAdapter adapter; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + 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: 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()), + ), + ), + ), + ], + ); + }, + ); + }, + ), + ), + 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(), + ); + }, + ), + ], + ), + 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()}%'), + ); + }, + ), + ], + ), + ], + ); + } + + static String _formatDuration(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} diff --git a/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/lib/modules/platform/usb_detector/AI_ANALYSIS.md index 1cee5c5..eba8aa4 100644 --- a/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/platform/usb_detector/module_root.dart b/lib/modules/platform/usb_detector/module_root.dart index 5691cce..2b0b80b 100644 --- a/lib/modules/platform/usb_detector/module_root.dart +++ b/lib/modules/platform/usb_detector/module_root.dart @@ -132,9 +132,7 @@ class _MyHomePageState extends State { const SizedBox(height: 16), Text( _isInitialized ? '未检测到USB设备' : 'USB服务未初始化', - style: Theme.of(context) - .textTheme - .bodyLarge + style: Theme.of(context).textTheme.bodyLarge ?.copyWith(color: Colors.grey[600]), ), ], @@ -169,8 +167,9 @@ class _MyHomePageState extends State { vertical: 4, ), decoration: BoxDecoration( - color: _getStatusColor(device.status) - .withValues(alpha: 0.1), + color: _getStatusColor( + device.status, + ).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( color: _getStatusColor(device.status), @@ -195,21 +194,20 @@ class _MyHomePageState extends State { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Flutter 中 USB 设备检测的实现方式', - '掌握 MethodChannel 与原生平台通信的模式', - '学会使用 Stream 监听设备插拔事件', - ]), - ConceptChips(concepts: [ - 'USB', - '设备检测', - 'MethodChannel', - 'Stream', - '平台通道', - ]), + LearningObjectives( + objectives: [ + '理解 Flutter 中 USB 设备检测的实现方式', + '掌握 MethodChannel 与原生平台通信的模式', + '学会使用 Stream 监听设备插拔事件', + ], + ), + ConceptChips( + concepts: ['USB', '设备检测', 'MethodChannel', 'Stream', '平台通道'], + ), CodeSnippetCard( title: 'USB 检测服务使用', - code: 'final service = UsbDetectionService();\n' + code: + 'final service = UsbDetectionService();\n' 'await service.initialize();\n' 'service.deviceStream.listen((devices) {\n' ' // 设备列表更新\n' @@ -221,11 +219,13 @@ class _MyHomePageState extends State { 'service.dispose();', explanation: 'UsbDetectionService 封装了平台通道调用和设备状态管理。', ), - CommonPitfalls(pitfalls: [ - 'USB 检测需要平台特定权限 — macOS 需在 entitlements 中声明,Android 需声明 USB 权限', - '平台通道需在后台线程操作 — USB 通信可能阻塞,避免在主 Isolate 中执行耗时操作', - '设备热插拔监听需及时注册 — initState 中启动监听,dispose 中释放', - ]), + CommonPitfalls( + pitfalls: [ + 'USB 检测需要平台特定权限 — macOS 需在 entitlements 中声明,Android 需声明 USB 权限', + '平台通道需在后台线程操作 — USB 通信可能阻塞,避免在主 Isolate 中执行耗时操作', + '设备热插拔监听需及时注册 — initState 中启动监听,dispose 中释放', + ], + ), ExerciseCard( task: '实现设备连接时的 Toast 或 SnackBar 提示,当 USB 设备插入时自动弹出通知。', hint: diff --git a/lib/modules/platform/usb_detector/services/usb_detection_service.dart b/lib/modules/platform/usb_detector/services/usb_detection_service.dart index 534040d..192753a 100644 --- a/lib/modules/platform/usb_detector/services/usb_detection_service.dart +++ b/lib/modules/platform/usb_detector/services/usb_detection_service.dart @@ -45,8 +45,10 @@ class UsbDetectionService { return _isInitialized; } catch (e) { - developer.log('USB initialization failed: $e', - name: 'UsbDetectionService'); + developer.log( + 'USB initialization failed: $e', + name: 'UsbDetectionService', + ); _statusStreamController.add('初始化错误: $e'); return false; } @@ -74,8 +76,10 @@ class UsbDetectionService { deviceInfoList.add(deviceInfo); } catch (e) { - developer.log('Error getting device info: $e', - name: 'UsbDetectionService'); + developer.log( + 'Error getting device info: $e', + name: 'UsbDetectionService', + ); UsbDeviceInfo deviceInfo = UsbDeviceInfo( vendorId: device.vid ?? 0, @@ -91,8 +95,10 @@ class UsbDetectionService { _statusStreamController.add('发现 ${_connectedDevices.length} 个USB设备'); } catch (e) { - developer.log('Error refreshing device list: $e', - name: 'UsbDetectionService'); + developer.log( + 'Error refreshing device list: $e', + name: 'UsbDetectionService', + ); _statusStreamController.add('扫描错误: $e'); } } @@ -143,8 +149,10 @@ class UsbDetectionService { WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; return '系统:${windowsInfo.productName} ${windowsInfo.displayVersion}'; } catch (e) { - developer.log('Error getting system info: $e', - name: 'UsbDetectionService'); + developer.log( + 'Error getting system info: $e', + name: 'UsbDetectionService', + ); } return '系统信息获取失败'; } diff --git a/lib/modules/popup_table/AI_ANALYSIS.md b/lib/modules/popup_table/AI_ANALYSIS.md index acc16dc..f6e4dba 100644 --- a/lib/modules/popup_table/AI_ANALYSIS.md +++ b/lib/modules/popup_table/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index a5b7cf0..37321d5 100644 --- a/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -30,8 +30,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/popup_table/overlay_follow_compare/widgets/dropdown_surface.dart b/lib/modules/popup_table/overlay_follow_compare/widgets/dropdown_surface.dart index be27be1..8bef526 100644 --- a/lib/modules/popup_table/overlay_follow_compare/widgets/dropdown_surface.dart +++ b/lib/modules/popup_table/overlay_follow_compare/widgets/dropdown_surface.dart @@ -25,10 +25,7 @@ class DropdownSurface extends StatelessWidget { decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: color.withValues(alpha: 0.3), - width: 2, - ), + border: Border.all(color: color.withValues(alpha: 0.3), width: 2), ), padding: const EdgeInsets.all(12), child: Column( diff --git a/lib/modules/popup_table/overlay_follow_compare/widgets/follower_demo.dart b/lib/modules/popup_table/overlay_follow_compare/widgets/follower_demo.dart index d8ad4e2..ea27708 100644 --- a/lib/modules/popup_table/overlay_follow_compare/widgets/follower_demo.dart +++ b/lib/modules/popup_table/overlay_follow_compare/widgets/follower_demo.dart @@ -79,10 +79,7 @@ class _FollowerDemoState extends State { Widget _buildButton(String label) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: ElevatedButton( - onPressed: _toggleOverlay, - child: Text(label), - ), + child: ElevatedButton(onPressed: _toggleOverlay, child: Text(label)), ); } } diff --git a/lib/modules/popup_table/overlay_follow_compare/widgets/manual_demo.dart b/lib/modules/popup_table/overlay_follow_compare/widgets/manual_demo.dart index 2efafa8..e0ef02d 100644 --- a/lib/modules/popup_table/overlay_follow_compare/widgets/manual_demo.dart +++ b/lib/modules/popup_table/overlay_follow_compare/widgets/manual_demo.dart @@ -84,8 +84,10 @@ class _ManualRebuildDemoState extends State { itemBuilder: (context, index) { if (index == 8) { return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: ElevatedButton( key: _buttonKey, onPressed: _toggleOverlay, diff --git a/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index f48f268..8367145 100644 --- a/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/popup_table/popup_list_interaction/module_root.dart b/lib/modules/popup_table/popup_list_interaction/module_root.dart index 10c3a5f..4f2e4d0 100644 --- a/lib/modules/popup_table/popup_list_interaction/module_root.dart +++ b/lib/modules/popup_table/popup_list_interaction/module_root.dart @@ -34,29 +34,36 @@ class PopupListInteractionHome extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '掌握 Flutter 弹窗组件(Dialog、BottomSheet、Overlay)的使用', - '理解二维滚动表格的原理与实现', - '学习弹窗与列表的协同交互方式', - ]), - ConceptChips(concepts: [ - 'Dialog', - 'BottomSheet', - 'Overlay', - 'ContextMenu', - 'TableView', - '二维滚动', - ]), + LearningObjectives( + objectives: [ + '掌握 Flutter 弹窗组件(Dialog、BottomSheet、Overlay)的使用', + '理解二维滚动表格的原理与实现', + '学习弹窗与列表的协同交互方式', + ], + ), + ConceptChips( + concepts: [ + 'Dialog', + 'BottomSheet', + 'Overlay', + 'ContextMenu', + 'TableView', + '二维滚动', + ], + ), CodeSnippetCard( title: '弹窗与列表路由配置', - code: "context.push(PopupListInteractionRoutes.popup);\n" + code: + "context.push(PopupListInteractionRoutes.popup);\n" "context.push(PopupListInteractionRoutes.list);", explanation: '模块内部使用 go_router 子路由管理多个演示页面。', ), - CommonPitfalls(pitfalls: [ - 'OverlayEntry 需在 dispose 时清理 — 否则会造成内存泄漏', - 'BottomSheet 在 ListView 中可能出现手势冲突 — 注意 GestureDetector 的嵌套', - ]), + CommonPitfalls( + pitfalls: [ + 'OverlayEntry 需在 dispose 时清理 — 否则会造成内存泄漏', + 'BottomSheet 在 ListView 中可能出现手势冲突 — 注意 GestureDetector 的嵌套', + ], + ), ExerciseCard( task: '在列表页中长按列表项弹出 ContextMenu,选择后执行对应操作。', hint: diff --git a/lib/modules/popup_table/popup_list_interaction/module_routes.dart b/lib/modules/popup_table/popup_list_interaction/module_routes.dart index b8bcd5f..1eef171 100644 --- a/lib/modules/popup_table/popup_list_interaction/module_routes.dart +++ b/lib/modules/popup_table/popup_list_interaction/module_routes.dart @@ -10,13 +10,7 @@ class PopupListInteractionRoutes { static const String list = '/list'; static List get routes => [ - GoRoute( - path: 'popup', - builder: (_, __) => const PopupPage(), - ), - GoRoute( - path: 'list', - builder: (_, __) => const ListPage(), - ), - ]; + GoRoute(path: 'popup', builder: (_, __) => const PopupPage()), + GoRoute(path: 'list', builder: (_, __) => const ListPage()), + ]; } diff --git a/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index 45c55e4..bcc4681 100644 --- a/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -30,8 +30,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/popup_table/popup_widgets/module_root.dart b/lib/modules/popup_table/popup_widgets/module_root.dart index b234b97..579bb54 100644 --- a/lib/modules/popup_table/popup_widgets/module_root.dart +++ b/lib/modules/popup_table/popup_widgets/module_root.dart @@ -115,8 +115,9 @@ class _PopDemoHomePageState extends State { ), ); if (!mounted || result == null) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('选择了: $result'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('选择了: $result'))); } Future _showModalBottomSheet() async { @@ -205,10 +206,7 @@ class _PopDemoHomePageState extends State { } Future _showTimePicker() async { - await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); + await showTimePicker(context: context, initialTime: TimeOfDay.now()); } void _showAbout() { @@ -236,8 +234,9 @@ class _PopDemoHomePageState extends State { ], ); if (!mounted || selected == null) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('选择了: $selected'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('选择了: $selected'))); } } @@ -315,17 +314,17 @@ extension _PopupDialogRoutes on _PopDemoHomePageState { ), ]); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已按顺序打开:A → B → C')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已按顺序打开:A → B → C'))); } Future _demoCloseChain() async { await _closeDialogsInOrder(const ['B', 'A', 'C']); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已按顺序关闭:B → A → C')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已按顺序关闭:B → A → C'))); } void _closeDialogById(String id) { @@ -348,10 +347,7 @@ extension _PopupOverlayDialogs on _PopDemoHomePageState { ); final content = OverlayEntry( builder: (context) => Center( - child: Material( - type: MaterialType.transparency, - child: dialog, - ), + child: Material(type: MaterialType.transparency, child: dialog), ), ); return [barrier, content]; diff --git a/lib/modules/popup_table/popup_widgets/widgets/bottom_sheet_demo.dart b/lib/modules/popup_table/popup_widgets/widgets/bottom_sheet_demo.dart index fff81dd..abe3de2 100644 --- a/lib/modules/popup_table/popup_widgets/widgets/bottom_sheet_demo.dart +++ b/lib/modules/popup_table/popup_widgets/widgets/bottom_sheet_demo.dart @@ -15,10 +15,7 @@ class PersistentBottomSheetBar extends StatelessWidget { const Icon(Icons.tips_and_updates_outlined), const SizedBox(width: 8), const Expanded(child: Text('这是一个持久化底部工具条,你可以手动关闭。')), - TextButton( - onPressed: onClose, - child: const Text('关闭'), - ), + TextButton(onPressed: onClose, child: const Text('关闭')), ], ), ); @@ -109,8 +106,10 @@ class _OverlayOrderEditorState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('编辑 Overlay 打开顺序', - style: Theme.of(context).textTheme.titleMedium), + Text( + '编辑 Overlay 打开顺序', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), _OrderList( keyPrefix: 'open', @@ -118,8 +117,10 @@ class _OverlayOrderEditorState extends State { onReorder: _reorderOpen, ), const SizedBox(height: 12), - Text('编辑 Overlay 关闭顺序', - style: Theme.of(context).textTheme.titleMedium), + Text( + '编辑 Overlay 关闭顺序', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), _OrderList( keyPrefix: 'close', @@ -209,15 +210,9 @@ class _EditorActions extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: onReset, - child: const Text('重置'), - ), + TextButton(onPressed: onReset, child: const Text('重置')), const SizedBox(width: 8), - ElevatedButton( - onPressed: onSave, - child: const Text('保存'), - ), + ElevatedButton(onPressed: onSave, child: const Text('保存')), ], ); } diff --git a/lib/modules/popup_table/popup_widgets/widgets/demo_section.dart b/lib/modules/popup_table/popup_widgets/widgets/demo_section.dart index 0b9b9a5..2f76102 100644 --- a/lib/modules/popup_table/popup_widgets/widgets/demo_section.dart +++ b/lib/modules/popup_table/popup_widgets/widgets/demo_section.dart @@ -8,11 +8,12 @@ const List kChainDialogIds = ['A', 'B', 'C']; class ChainOrderStore { ChainOrderStore({List? initial}) - : openOrder = - ValueNotifier>(List.of(initial ?? kChainDialogIds)), - closeOrder = ValueNotifier>( - List.of((initial ?? kChainDialogIds).reversed), - ); + : openOrder = ValueNotifier>( + List.of(initial ?? kChainDialogIds), + ), + closeOrder = ValueNotifier>( + List.of((initial ?? kChainDialogIds).reversed), + ); final ValueNotifier> openOrder; final ValueNotifier> closeOrder; @@ -124,8 +125,9 @@ class _PopupDemoToolbar extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - border: - Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), ), child: Row( children: [ @@ -278,11 +280,15 @@ class _NavigatorChainSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('顺序链式弹窗(自定义开关顺序)', - style: Theme.of(context).textTheme.titleMedium), + Text( + '顺序链式弹窗(自定义开关顺序)', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 6), - Text('示例:打开 A→B→C;关闭 B→A→C', - style: Theme.of(context).textTheme.bodySmall), + Text( + '示例:打开 A→B→C;关闭 B→A→C', + style: Theme.of(context).textTheme.bodySmall, + ), const SizedBox(height: 8), Wrap( spacing: 8, @@ -324,8 +330,10 @@ class _OverlayChainSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Overlay 链式弹窗(对比 Navigator)', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'Overlay 链式弹窗(对比 Navigator)', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 6), ValueListenableBuilder>( valueListenable: orderStore.openOrder, diff --git a/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart b/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart index 345926c..6f36706 100644 --- a/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart +++ b/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart @@ -3,27 +3,32 @@ import 'package:flutter_study_learning/flutter_study_learning.dart'; List buildPopupLearningSections() { return const [ - LearningObjectives(objectives: [ - '掌握 Flutter 中多种弹窗的创建方式', - '理解 AlertDialog、SimpleDialog、BottomSheet 的区别与适用场景', - '掌握通过 Navigator 管理链式对话框', - '学习使用 OverlayEntry 自定义弹窗', - '理解 ContextMenu(showMenu)的触发方式', - ]), - ConceptChips(concepts: [ - 'showDialog', - 'AlertDialog', - 'SimpleDialog', - 'BottomSheet', - 'showMenu', - 'OverlayEntry', - 'Navigator', - 'showDatePicker', - 'showTimePicker', - ]), + LearningObjectives( + objectives: [ + '掌握 Flutter 中多种弹窗的创建方式', + '理解 AlertDialog、SimpleDialog、BottomSheet 的区别与适用场景', + '掌握通过 Navigator 管理链式对话框', + '学习使用 OverlayEntry 自定义弹窗', + '理解 ContextMenu(showMenu)的触发方式', + ], + ), + ConceptChips( + concepts: [ + 'showDialog', + 'AlertDialog', + 'SimpleDialog', + 'BottomSheet', + 'showMenu', + 'OverlayEntry', + 'Navigator', + 'showDatePicker', + 'showTimePicker', + ], + ), CodeSnippetCard( title: 'AlertDialog 基础用法', - code: 'Future showAlertDialog() async {\n' + code: + 'Future showAlertDialog() async {\n' ' await showDialog(\n' ' context: context,\n' ' builder: (context) => AlertDialog(\n' @@ -42,7 +47,8 @@ List buildPopupLearningSections() { ), CodeSnippetCard( title: 'Modal Bottom Sheet', - code: 'Future showBottomSheetDemo() async {\n' + code: + 'Future showBottomSheetDemo() async {\n' ' await showModalBottomSheet(\n' ' context: context,\n' ' showDragHandle: true,\n' @@ -65,12 +71,14 @@ List buildPopupLearningSections() { '}', explanation: 'showModalBottomSheet 从屏幕底部滑入,支持拖动关闭。', ), - CommonPitfalls(pitfalls: [ - '忘记 Navigator.pop(context) — 对话框不会自动关闭,需要在按钮回调中显式调用 Navigator.pop(context)', - 'context 生命周期 — 异步操作后需检查 mounted,否则调用 Navigator.pop(context) 可能抛异常', - 'showDialog 与 showCupertinoDialog 使用不同的主题上下文,不可混用', - 'OverlayEntry 需手动管理 — 不会自动释放,必须在 dispose 时清理所有 entry', - ]), + CommonPitfalls( + pitfalls: [ + '忘记 Navigator.pop(context) — 对话框不会自动关闭,需要在按钮回调中显式调用 Navigator.pop(context)', + 'context 生命周期 — 异步操作后需检查 mounted,否则调用 Navigator.pop(context) 可能抛异常', + 'showDialog 与 showCupertinoDialog 使用不同的主题上下文,不可混用', + 'OverlayEntry 需手动管理 — 不会自动释放,必须在 dispose 时清理所有 entry', + ], + ), ExerciseCard( task: '创建一个包含输入框的自定义对话框,用户输入文字后点击"提交",在 SnackBar 中显示输入内容。', hint: diff --git a/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index f0a06a4..5e49cbe 100644 --- a/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/popup_table/scroll_table/module_root.dart b/lib/modules/popup_table/scroll_table/module_root.dart index 786f662..cf06bc6 100644 --- a/lib/modules/popup_table/scroll_table/module_root.dart +++ b/lib/modules/popup_table/scroll_table/module_root.dart @@ -38,21 +38,26 @@ class ScrollTableDemo extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '掌握二维滚动表格的基本使用方式', - '理解固定表头与行头的实现原理', - '学会使用 TableView 处理大量数据展示', - ]), - ConceptChips(concepts: [ - 'TableView', - '二维滚动', - '固定表头', - '行头', - 'two_dimensional_scrollables', - ]), + LearningObjectives( + objectives: [ + '掌握二维滚动表格的基本使用方式', + '理解固定表头与行头的实现原理', + '学会使用 TableView 处理大量数据展示', + ], + ), + ConceptChips( + concepts: [ + 'TableView', + '二维滚动', + '固定表头', + '行头', + 'two_dimensional_scrollables', + ], + ), CodeSnippetCard( title: 'ScrollTable 使用示例', - code: 'ScrollTable(\n' + code: + 'ScrollTable(\n' ' columnHeaders: columnHeaders,\n' ' rowHeaders: rowHeaders,\n' ' data: sampleData,\n' @@ -61,10 +66,12 @@ class ScrollTableDemo extends StatelessWidget { ')', explanation: 'ScrollTable 封装了 TableView 的常见配置,简化使用。', ), - CommonPitfalls(pitfalls: [ - '数据量大时需注意性能 — TableView 本身支持懒加载,但 cellWidget 避免复杂构建', - '宽高需明确指定 — TableView 的单元格宽高必须固定,不支持自适应', - ]), + CommonPitfalls( + pitfalls: [ + '数据量大时需注意性能 — TableView 本身支持懒加载,但 cellWidget 避免复杂构建', + '宽高需明确指定 — TableView 的单元格宽高必须固定,不支持自适应', + ], + ), ExerciseCard( task: '在现有表格基础上增加一列"操作",包含编辑和删除按钮。', hint: '在 columnHeaders 和 data 中同步增加列,TableData 数据类中增加对应字段。', diff --git a/lib/modules/popup_table/scroll_table/widgets/scroll_table.dart b/lib/modules/popup_table/scroll_table/widgets/scroll_table.dart index 389c5b8..599101a 100644 --- a/lib/modules/popup_table/scroll_table/widgets/scroll_table.dart +++ b/lib/modules/popup_table/scroll_table/widgets/scroll_table.dart @@ -63,8 +63,9 @@ class ScrollTable extends StatelessWidget { textStyle = const TextStyle(fontWeight: FontWeight.bold); } else { // 数据单元格 - backgroundColor = - vicinity.row % 2 == 0 ? Colors.white : Colors.grey.shade50; + backgroundColor = vicinity.row % 2 == 0 + ? Colors.white + : Colors.grey.shade50; text = data[vicinity.row - 1][vicinity.column - 1]; textStyle = const TextStyle(); } @@ -94,15 +95,11 @@ class ScrollTable extends StatelessWidget { } TableSpan _buildColumn(int index) { - return TableSpan( - extent: FixedTableSpanExtent(cellWidth), - ); + return TableSpan(extent: FixedTableSpanExtent(cellWidth)); } TableSpan _buildRow(int index) { - return TableSpan( - extent: FixedTableSpanExtent(cellHeight), - ); + return TableSpan(extent: FixedTableSpanExtent(cellHeight)); } } diff --git a/lib/modules/state/AI_ANALYSIS.md b/lib/modules/state/AI_ANALYSIS.md index c92a46b..2b1ae20 100644 --- a/lib/modules/state/AI_ANALYSIS.md +++ b/lib/modules/state/AI_ANALYSIS.md @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index 11fea91..4607156 100644 --- a/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/state/flutter_ioc/model/counter_model.dart b/lib/modules/state/flutter_ioc/model/counter_model.dart index 1b96721..460b4d4 100644 --- a/lib/modules/state/flutter_ioc/model/counter_model.dart +++ b/lib/modules/state/flutter_ioc/model/counter_model.dart @@ -8,8 +8,8 @@ class CounterModel extends ChangeNotifier { // Default constructor CounterModel({int count = 0, String name = "Default Counter"}) - : _count = count, - _name = name; + : _count = count, + _name = name; int get count => _count; String get name => _name; @@ -25,10 +25,7 @@ class CounterModel extends ChangeNotifier { } Map toMap() { - return { - 'count': _count, - 'name': _name, - }; + return {'count': _count, 'name': _name}; } factory CounterModel.fromMap(Map map) { diff --git a/lib/modules/state/flutter_ioc/module_root.dart b/lib/modules/state/flutter_ioc/module_root.dart index 60e227f..89c46fc 100644 --- a/lib/modules/state/flutter_ioc/module_root.dart +++ b/lib/modules/state/flutter_ioc/module_root.dart @@ -47,22 +47,27 @@ class CounterScreen extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 IoC 容器与依赖注入的基本概念', - '掌握单例、瞬态、作用域三种生命周期', - '学会使用 Provider 与 IoC 容器集成', - ]), - ConceptChips(concepts: [ - 'IoC', - '依赖注入', - 'Singleton', - 'Transient', - 'Scoped', - 'Provider', - ]), + LearningObjectives( + objectives: [ + '理解 IoC 容器与依赖注入的基本概念', + '掌握单例、瞬态、作用域三种生命周期', + '学会使用 Provider 与 IoC 容器集成', + ], + ), + ConceptChips( + concepts: [ + 'IoC', + '依赖注入', + 'Singleton', + 'Transient', + 'Scoped', + 'Provider', + ], + ), CodeSnippetCard( title: 'IoC 容器注册与使用', - code: 'final container = Container(\n' + code: + 'final container = Container(\n' " environment: {'appName': 'Counter'},\n" ');\n' 'container.registerSingleton(\n' @@ -71,11 +76,13 @@ class CounterScreen extends StatelessWidget { 'final model = container.resolve();', explanation: '容器管理对象生命周期,模块无需关心实例化细节。', ), - CommonPitfalls(pitfalls: [ - '忘记在 dispose 中释放容器 — IoC 容器不会自动回收注册的对象', - '循环依赖 — 容器无法自动检测循环依赖,需自行注意依赖方向', - '过度使用全局单例 — 单例作用域应尽量缩小,避免状态污染', - ]), + CommonPitfalls( + pitfalls: [ + '忘记在 dispose 中释放容器 — IoC 容器不会自动回收注册的对象', + '循环依赖 — 容器无法自动检测循环依赖,需自行注意依赖方向', + '过度使用全局单例 — 单例作用域应尽量缩小,避免状态污染', + ], + ), ExerciseCard( task: '注册一个 Transient 作用域的 Service,每次 resolve 都返回新实例,验证行为。', hint: diff --git a/lib/modules/state/status_management/AI_ANALYSIS.md b/lib/modules/state/status_management/AI_ANALYSIS.md index 08f6dde..d01c9cc 100644 --- a/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/lib/modules/state/status_management/AI_ANALYSIS.md @@ -35,8 +35,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/state/status_management/module_routes.dart b/lib/modules/state/status_management/module_routes.dart index 935e745..043c5f7 100644 --- a/lib/modules/state/status_management/module_routes.dart +++ b/lib/modules/state/status_management/module_routes.dart @@ -14,14 +14,14 @@ class StatusManagementRoutes { StatusManagementRoutes._(); static Map get routes => { - '/provider': (_) => const ProviderRoute(), - '/provider/lifting': (_) => const ProviderLiftingRoute(), - '/provider/future': (_) => const ProviderFutureRoute(), - '/provider/todo': (_) => const ProviderTodoRoute(), - '/riverpod': (_) => const RiverpodRoute(), - '/riverpod/lifting': (_) => const RiverpodLiftingRoute(), - '/riverpod/future': (_) => const RiverpodFutureRoute(), - '/riverpod/todo': (_) => const RiverpodTodoRoute(), - '/bloc': (_) => const BlocRoute(), - }; + '/provider': (_) => const ProviderRoute(), + '/provider/lifting': (_) => const ProviderLiftingRoute(), + '/provider/future': (_) => const ProviderFutureRoute(), + '/provider/todo': (_) => const ProviderTodoRoute(), + '/riverpod': (_) => const RiverpodRoute(), + '/riverpod/lifting': (_) => const RiverpodLiftingRoute(), + '/riverpod/future': (_) => const RiverpodFutureRoute(), + '/riverpod/todo': (_) => const RiverpodTodoRoute(), + '/bloc': (_) => const BlocRoute(), + }; } diff --git a/lib/modules/state/status_management/pages/bloc/bloc_route.dart b/lib/modules/state/status_management/pages/bloc/bloc_route.dart index dd80c4e..9a2e181 100644 --- a/lib/modules/state/status_management/pages/bloc/bloc_route.dart +++ b/lib/modules/state/status_management/pages/bloc/bloc_route.dart @@ -34,17 +34,15 @@ class BlocRoute extends StatelessWidget { onReset: () => bloc.add(ResetPressed()), ), sections: [ - LearningObjectives(objectives: [ - '理解 Bloc 的事件驱动状态管理机制', - '掌握 Event → Bloc → State 的完整链路', - ]), - ConceptChips(concepts: [ - 'Bloc', - 'Event', - 'State', - 'emit', - 'BlocBuilder', - ]), + LearningObjectives( + objectives: [ + '理解 Bloc 的事件驱动状态管理机制', + '掌握 Event → Bloc → State 的完整链路', + ], + ), + ConceptChips( + concepts: ['Bloc', 'Event', 'State', 'emit', 'BlocBuilder'], + ), CodeSnippetCard( title: 'Bloc 核心模式', code: diff --git a/lib/modules/state/status_management/pages/bloc/counter_bloc.dart b/lib/modules/state/status_management/pages/bloc/counter_bloc.dart index f87b977..3b9f9b8 100644 --- a/lib/modules/state/status_management/pages/bloc/counter_bloc.dart +++ b/lib/modules/state/status_management/pages/bloc/counter_bloc.dart @@ -12,7 +12,9 @@ class CounterBloc extends Bloc { } Future _onLoadInitial( - LoadInitial event, Emitter emit) async { + LoadInitial event, + Emitter emit, + ) async { emit(state.copyWith(status: CounterStatus.loading)); try { await Future.delayed(const Duration(milliseconds: 200)); diff --git a/lib/modules/state/status_management/pages/bloc/counter_state.dart b/lib/modules/state/status_management/pages/bloc/counter_state.dart index a14b0f5..c5efe32 100644 --- a/lib/modules/state/status_management/pages/bloc/counter_state.dart +++ b/lib/modules/state/status_management/pages/bloc/counter_state.dart @@ -3,8 +3,11 @@ import 'package:equatable/equatable.dart'; enum CounterStatus { initial, loading, success, failure } class CounterState extends Equatable { - const CounterState( - {this.value = 0, this.status = CounterStatus.initial, this.error}); + const CounterState({ + this.value = 0, + this.status = CounterStatus.initial, + this.error, + }); final int value; final CounterStatus status; diff --git a/lib/modules/state/status_management/pages/home_page.dart b/lib/modules/state/status_management/pages/home_page.dart index 8813238..aaf09e5 100644 --- a/lib/modules/state/status_management/pages/home_page.dart +++ b/lib/modules/state/status_management/pages/home_page.dart @@ -17,29 +17,33 @@ class StateFlowHome extends StatelessWidget { icon: Icons.extension, routes: [ _HomeCardData( - title: '基础 / 粒度刷新', - flow: '事件 → notifyListeners → 重建', - icon: Icons.auto_fix_high, - routeName: '/provider', - chipLabel: 'ChangeNotifier'), + title: '基础 / 粒度刷新', + flow: '事件 → notifyListeners → 重建', + icon: Icons.auto_fix_high, + routeName: '/provider', + chipLabel: 'ChangeNotifier', + ), _HomeCardData( - title: '状态提升', - flow: '父级集中管理 → 子组件共享', - icon: Icons.vertical_align_top, - routeName: '/provider/lifting', - chipLabel: 'props 上提'), + title: '状态提升', + flow: '父级集中管理 → 子组件共享', + icon: Icons.vertical_align_top, + routeName: '/provider/lifting', + chipLabel: 'props 上提', + ), _HomeCardData( - title: '数据获取', - flow: '异步加载 → 缓存 → 重建', - icon: Icons.cloud_download, - routeName: '/provider/future', - chipLabel: 'FutureBuilder 缓存'), + title: '数据获取', + flow: '异步加载 → 缓存 → 重建', + icon: Icons.cloud_download, + routeName: '/provider/future', + chipLabel: 'FutureBuilder 缓存', + ), _HomeCardData( - title: '全局 Todo', - flow: '列表变更 → notifyListeners', - icon: Icons.checklist, - routeName: '/provider/todo', - chipLabel: 'ChangeNotifier'), + title: '全局 Todo', + flow: '列表变更 → notifyListeners', + icon: Icons.checklist, + routeName: '/provider/todo', + chipLabel: 'ChangeNotifier', + ), ], ), _RouteCategory( @@ -49,29 +53,33 @@ class StateFlowHome extends StatelessWidget { icon: Icons.sync_alt, routes: [ _HomeCardData( - title: 'StateNotifier 基础', - flow: '事件 → state=new → 重建', - icon: Icons.sync_alt, - routeName: '/riverpod', - chipLabel: '声明式图谱'), + title: 'StateNotifier 基础', + flow: '事件 → state=new → 重建', + icon: Icons.sync_alt, + routeName: '/riverpod', + chipLabel: '声明式图谱', + ), _HomeCardData( - title: '状态提升', - flow: 'Provider 图谱共享', - icon: Icons.vertical_align_top, - routeName: '/riverpod/lifting', - chipLabel: 'StateNotifierProvider'), + title: '状态提升', + flow: 'Provider 图谱共享', + icon: Icons.vertical_align_top, + routeName: '/riverpod/lifting', + chipLabel: 'StateNotifierProvider', + ), _HomeCardData( - title: '数据获取', - flow: 'FutureProvider → when()', - icon: Icons.cloud_download, - routeName: '/riverpod/future', - chipLabel: '缓存与错误处理'), + title: '数据获取', + flow: 'FutureProvider → when()', + icon: Icons.cloud_download, + routeName: '/riverpod/future', + chipLabel: '缓存与错误处理', + ), _HomeCardData( - title: '全局 Todo', - flow: '列表不可变 → state 赋值广播', - icon: Icons.checklist, - routeName: '/riverpod/todo', - chipLabel: 'StateNotifier'), + title: '全局 Todo', + flow: '列表不可变 → state 赋值广播', + icon: Icons.checklist, + routeName: '/riverpod/todo', + chipLabel: 'StateNotifier', + ), ], ), _RouteCategory( @@ -81,11 +89,12 @@ class StateFlowHome extends StatelessWidget { icon: Icons.scatter_plot, routes: [ _HomeCardData( - title: 'flutter_bloc 基础', - flow: 'Event → Bloc → emit(State) → 重建', - icon: Icons.scatter_plot, - routeName: '/bloc', - chipLabel: '事件流 + 不可变状态'), + title: 'flutter_bloc 基础', + flow: 'Event → Bloc → emit(State) → 重建', + icon: Icons.scatter_plot, + routeName: '/bloc', + chipLabel: '事件流 + 不可变状态', + ), ], ), ]; @@ -103,31 +112,38 @@ class StateFlowHome extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '理解三大状态管理框架的核心机制', - '对比 Provider、Riverpod、Bloc 的异同', - '掌握状态刷新链路:事件 → 状态 → 通知 → UI 重建', - ]), - ConceptChips(concepts: [ - 'Provider', - 'Riverpod', - 'Bloc', - 'ChangeNotifier', - 'StateNotifier', - '事件驱动', - ]), + LearningObjectives( + objectives: [ + '理解三大状态管理框架的核心机制', + '对比 Provider、Riverpod、Bloc 的异同', + '掌握状态刷新链路:事件 → 状态 → 通知 → UI 重建', + ], + ), + ConceptChips( + concepts: [ + 'Provider', + 'Riverpod', + 'Bloc', + 'ChangeNotifier', + 'StateNotifier', + '事件驱动', + ], + ), CodeSnippetCard( title: '状态刷新链路对比', - code: '// Provider: context.select + notifyListeners\n' + code: + '// Provider: context.select + notifyListeners\n' '// Riverpod: ref.watch + state = new\n' '// Bloc: context.read + emit(state)', explanation: '三种框架的核心理念不同,但都遵循"事件 → 状态 → UI"的刷新链路。', ), - CommonPitfalls(pitfalls: [ - 'Provider 的 context.select 只监听指定字段,避免不必要的重建', - 'Riverpod 的 Provider 是全局的,无需 Widget 树嵌套', - 'Bloc 的 Event/State 必须是不可变对象,使用 copyWith 或 freezed', - ]), + CommonPitfalls( + pitfalls: [ + 'Provider 的 context.select 只监听指定字段,避免不必要的重建', + 'Riverpod 的 Provider 是全局的,无需 Widget 树嵌套', + 'Bloc 的 Event/State 必须是不可变对象,使用 copyWith 或 freezed', + ], + ), ExerciseCard( task: '在 Provider "基础/粒度刷新"页面中观察 context.select 对重建粒度的影响。', hint: '打开调试控制台查看日志,点击加 1 按钮观察哪些 Widget 重建了。', @@ -183,9 +199,12 @@ class _RouteCategoryCard extends StatelessWidget { children: [ Text(category.title, style: theme.textTheme.titleLarge), const SizedBox(height: 6), - Text(category.description, - style: theme.textTheme.bodyMedium - ?.copyWith(color: Colors.black54)), + Text( + category.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.black54, + ), + ), ], ), ), @@ -231,8 +250,10 @@ class _RouteListTile extends StatelessWidget { title: Text(data.title, style: theme.textTheme.titleMedium), subtitle: Padding( padding: const EdgeInsets.only(top: 4), - child: Text('刷新链路:${data.flow}', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.black54)), + child: Text( + '刷新链路:${data.flow}', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.black54), + ), ), trailing: Chip( avatar: const Icon(Icons.visibility, size: 16), diff --git a/lib/modules/state/status_management/pages/provider/provider_future_route.dart b/lib/modules/state/status_management/pages/provider/provider_future_route.dart index 7b08f68..023449e 100644 --- a/lib/modules/state/status_management/pages/provider/provider_future_route.dart +++ b/lib/modules/state/status_management/pages/provider/provider_future_route.dart @@ -34,25 +34,22 @@ class _FutureContent extends StatelessWidget { child: Consumer<_UserModel>( builder: (_, m, __) => m.name == null ? const CircularProgressIndicator() - : Text(m.name!, - style: Theme.of(context).textTheme.headlineSmall), + : Text( + m.name!, + style: Theme.of(context).textTheme.headlineSmall, + ), ), ), ), sections: [ - LearningObjectives(objectives: [ - '掌握 Provider 中异步数据获取的模式', - '理解 ChangeNotifier 中的状态缓存机制', - ]), - ConceptChips(concepts: [ - 'Provider', - 'Future', - '缓存', - 'ChangeNotifier', - ]), + LearningObjectives( + objectives: ['掌握 Provider 中异步数据获取的模式', '理解 ChangeNotifier 中的状态缓存机制'], + ), + ConceptChips(concepts: ['Provider', 'Future', '缓存', 'ChangeNotifier']), CodeSnippetCard( title: 'Provider 异步加载', - code: 'class _UserModel extends ChangeNotifier {\n' + code: + 'class _UserModel extends ChangeNotifier {\n' ' String? name;\n' ' Future load() async {\n' ' name = await fetchUser();\n' diff --git a/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart b/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart index 16f5276..12abf99 100644 --- a/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart +++ b/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart @@ -36,27 +36,21 @@ class _LiftingContent extends StatelessWidget { height: 200, child: Column( mainAxisSize: MainAxisSize.min, - children: [ - _LDisplay(), - const SizedBox(height: 16), - _LControls(), - ], + children: [_LDisplay(), const SizedBox(height: 16), _LControls()], ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Provider 状态提升 (Lifting State Up) 模式', - '掌握父级集中管理状态、子组件共享的模式', - ]), - ConceptChips(concepts: [ - '状态提升', - 'Provider', - 'ChangeNotifier', - '共享状态', - ]), + LearningObjectives( + objectives: [ + '理解 Provider 状态提升 (Lifting State Up) 模式', + '掌握父级集中管理状态、子组件共享的模式', + ], + ), + ConceptChips(concepts: ['状态提升', 'Provider', 'ChangeNotifier', '共享状态']), CodeSnippetCard( title: '状态提升模式', - code: 'ChangeNotifierProvider(\n' + code: + 'ChangeNotifierProvider(\n' ' create: (_) => _LiftingCN(),\n' ' child: _LDisplay(),\n' ');\n' @@ -91,14 +85,16 @@ class _LControls extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ FilledButton.icon( - onPressed: s.inc, - icon: const Icon(Icons.exposure_plus_1), - label: const Text('加 1')), + onPressed: s.inc, + icon: const Icon(Icons.exposure_plus_1), + label: const Text('加 1'), + ), const SizedBox(width: 12), OutlinedButton.icon( - onPressed: s.reset, - icon: const Icon(Icons.restart_alt), - label: const Text('重置')), + onPressed: s.reset, + icon: const Icon(Icons.restart_alt), + label: const Text('重置'), + ), ], ); } diff --git a/lib/modules/state/status_management/pages/provider/provider_route.dart b/lib/modules/state/status_management/pages/provider/provider_route.dart index a925512..045c899 100644 --- a/lib/modules/state/status_management/pages/provider/provider_route.dart +++ b/lib/modules/state/status_management/pages/provider/provider_route.dart @@ -40,19 +40,24 @@ class ProviderRoute extends StatelessWidget { extra: const ProviderPerks(), ), sections: [ - LearningObjectives(objectives: [ - '理解 Provider + ChangeNotifier 的状态刷新链路', - '掌握 context.select 的粒度刷新机制', - ]), - ConceptChips(concepts: [ - 'Provider', - 'ChangeNotifier', - 'notifyListeners', - 'context.select', - ]), + LearningObjectives( + objectives: [ + '理解 Provider + ChangeNotifier 的状态刷新链路', + '掌握 context.select 的粒度刷新机制', + ], + ), + ConceptChips( + concepts: [ + 'Provider', + 'ChangeNotifier', + 'notifyListeners', + 'context.select', + ], + ), CodeSnippetCard( title: 'Provider 粒度刷新', - code: 'final value = context.select((s) => s.value);\n' + code: + 'final value = context.select((s) => s.value);\n' 'context.read().increment();', explanation: 'select 只监听指定字段,字段未变时 Widget 不会重建。', ), diff --git a/lib/modules/state/status_management/pages/provider/provider_todo_route.dart b/lib/modules/state/status_management/pages/provider/provider_todo_route.dart index d76ae15..6aba64c 100644 --- a/lib/modules/state/status_management/pages/provider/provider_todo_route.dart +++ b/lib/modules/state/status_management/pages/provider/provider_todo_route.dart @@ -55,29 +55,30 @@ class _TodoContent extends StatelessWidget { return ListTile( title: Text(item.title), leading: Checkbox( - value: item.done, onChanged: (_) => store.toggle(i)), + value: item.done, + onChanged: (_) => store.toggle(i), + ), trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => store.remove(i)), + icon: const Icon(Icons.delete_outline), + onPressed: () => store.remove(i), + ), ); }, ), ), ), sections: [ - LearningObjectives(objectives: [ - '掌握 Provider 管理全局列表状态', - '理解 notifyListeners 在 CRUD 操作中的触发时机', - ]), - ConceptChips(concepts: [ - 'Provider', - '全局状态', - 'CRUD', - 'ChangeNotifier', - ]), + LearningObjectives( + objectives: [ + '掌握 Provider 管理全局列表状态', + '理解 notifyListeners 在 CRUD 操作中的触发时机', + ], + ), + ConceptChips(concepts: ['Provider', '全局状态', 'CRUD', 'ChangeNotifier']), CodeSnippetCard( title: 'Provider Todo 模式', - code: 'class TodoStore extends ChangeNotifier {\n' + code: + 'class TodoStore extends ChangeNotifier {\n' ' final list = [];\n' ' void add(String t) { list.add(Todo(t)); notifyListeners(); }\n' ' void remove(int i) { list.removeAt(i); notifyListeners(); }\n' diff --git a/lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart b/lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart index 16b9afa..7a6c8a9 100644 --- a/lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart +++ b/lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart @@ -71,9 +71,9 @@ class GranularCard extends StatelessWidget { const SizedBox(height: 10), Text( '$selected', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), ), ], ), diff --git a/lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart b/lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart index 3686355..d830c7c 100644 --- a/lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart +++ b/lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart @@ -22,8 +22,10 @@ class ProviderPerks extends StatelessWidget { const SizedBox(height: 12), const DeepTree(), const SizedBox(height: 18), - Text('颗粒度刷新(Selector / context.select)', - style: theme.textTheme.titleMedium), + Text( + '颗粒度刷新(Selector / context.select)', + style: theme.textTheme.titleMedium, + ), const SizedBox(height: 6), Text( '不同区域只监听自己关心的字段:点击叶子不会让顶部数值重建,反之亦然。控制台日志可看到哪些 build() 被触发。', @@ -83,10 +85,9 @@ class TreeLevelTwo extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.4), + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(10), ), child: const Column( @@ -109,22 +110,21 @@ class TreeLeaf extends StatelessWidget { final leafTaps = context.select((s) => s.leafTaps); final ancestorValue = context.select((s) => s.value); debugPrint( - '[Provider] 最深叶子 build:leafTaps=$leafTaps, ancestor value=$ancestorValue'); + '[Provider] 最深叶子 build:leafTaps=$leafTaps, ancestor value=$ancestorValue', + ); return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.04), + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(10), border: Border.all( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.12), + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer.withValues(alpha: 0.12), ), ), child: Column( diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart index 6af675f..25c8aef 100644 --- a/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart @@ -22,20 +22,19 @@ class RiverpodFutureRoute extends ConsumerWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '掌握 Riverpod FutureProvider 的异步数据管理', - '理解 when() 方法处理 loading/data/error 三态', - ]), - ConceptChips(concepts: [ - 'Riverpod', - 'FutureProvider', - 'async', - 'when', - '缓存', - ]), + LearningObjectives( + objectives: [ + '掌握 Riverpod FutureProvider 的异步数据管理', + '理解 when() 方法处理 loading/data/error 三态', + ], + ), + ConceptChips( + concepts: ['Riverpod', 'FutureProvider', 'async', 'when', '缓存'], + ), CodeSnippetCard( title: 'FutureProvider 模式', - code: 'final userProvider = FutureProvider((ref) async {\n' + code: + 'final userProvider = FutureProvider((ref) async {\n' ' await Future.delayed(Duration(milliseconds: 300));\n' ' return "Alice";\n' '});\n' diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart index 59083e5..e570a83 100644 --- a/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart @@ -13,27 +13,25 @@ class RiverpodLiftingRoute extends ConsumerWidget { height: 200, child: Column( mainAxisSize: MainAxisSize.min, - children: [ - _LDisplay(), - const SizedBox(height: 16), - _LControls(), - ], + children: [_LDisplay(), const SizedBox(height: 16), _LControls()], ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Riverpod 的状态提升模式', - '掌握 Provider 图谱中状态的共享机制', - ]), - ConceptChips(concepts: [ - 'Riverpod', - '状态提升', - 'Provider 图谱', - 'StateNotifierProvider', - ]), + LearningObjectives( + objectives: ['理解 Riverpod 的状态提升模式', '掌握 Provider 图谱中状态的共享机制'], + ), + ConceptChips( + concepts: [ + 'Riverpod', + '状态提升', + 'Provider 图谱', + 'StateNotifierProvider', + ], + ), CodeSnippetCard( title: 'Riverpod 状态提升', - code: 'final liftProvider = StateNotifierProvider(\n' + code: + 'final liftProvider = StateNotifierProvider(\n' ' (ref) => LiftRP(),\n' ');\n' '// 任意组件可 watch/read 同一 Provider', @@ -74,14 +72,16 @@ class _LControls extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ FilledButton.icon( - onPressed: n.inc, - icon: const Icon(Icons.exposure_plus_1), - label: const Text('加 1')), + onPressed: n.inc, + icon: const Icon(Icons.exposure_plus_1), + label: const Text('加 1'), + ), const SizedBox(width: 12), OutlinedButton.icon( - onPressed: n.reset, - icon: const Icon(Icons.restart_alt), - label: const Text('重置')), + onPressed: n.reset, + icon: const Icon(Icons.restart_alt), + label: const Text('重置'), + ), ], ); } diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart index 43e5a67..85cfcff 100644 --- a/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart @@ -28,17 +28,21 @@ class RiverpodRoute extends ConsumerWidget { onReset: () => ref.read(counterProvider.notifier).reset(), ), sections: [ - LearningObjectives(objectives: [ - '理解 Riverpod StateNotifier 的状态管理机制', - '掌握 Provider 容器广播与消费者重建的关系', - ]), - ConceptChips(concepts: [ - 'Riverpod', - 'StateNotifier', - 'ProviderContainer', - 'ref.watch', - 'ref.read', - ]), + LearningObjectives( + objectives: [ + '理解 Riverpod StateNotifier 的状态管理机制', + '掌握 Provider 容器广播与消费者重建的关系', + ], + ), + ConceptChips( + concepts: [ + 'Riverpod', + 'StateNotifier', + 'ProviderContainer', + 'ref.watch', + 'ref.read', + ], + ), CodeSnippetCard( title: 'Riverpod 核心模式', code: diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart index 6077624..7fa4286 100644 --- a/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart @@ -32,8 +32,9 @@ class _TodoRP extends StateNotifier> { } } -final _todoProvider = - StateNotifierProvider<_TodoRP, List<_Todo>>((ref) => _TodoRP()); +final _todoProvider = StateNotifierProvider<_TodoRP, List<_Todo>>( + (ref) => _TodoRP(), +); class _TodoContent extends ConsumerWidget { @override @@ -61,30 +62,29 @@ class _TodoContent extends ConsumerWidget { final item = list[i]; return ListTile( title: Text(item.title), - leading: - Checkbox(value: item.done, onChanged: (_) => n.toggle(i)), + leading: Checkbox( + value: item.done, + onChanged: (_) => n.toggle(i), + ), trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => n.remove(i)), + icon: const Icon(Icons.delete_outline), + onPressed: () => n.remove(i), + ), ); }, ), ), sections: [ - LearningObjectives(objectives: [ - '掌握 Riverpod 管理全局列表状态', - '理解 StateNotifier 不可变状态更新模式', - ]), - ConceptChips(concepts: [ - 'Riverpod', - '全局状态', - 'CRUD', - '不可变数据', - 'StateNotifier', - ]), + LearningObjectives( + objectives: ['掌握 Riverpod 管理全局列表状态', '理解 StateNotifier 不可变状态更新模式'], + ), + ConceptChips( + concepts: ['Riverpod', '全局状态', 'CRUD', '不可变数据', 'StateNotifier'], + ), CodeSnippetCard( title: 'Riverpod Todo 模式', - code: 'class TodoRP extends StateNotifier> {\n' + code: + 'class TodoRP extends StateNotifier> {\n' ' void add(t) => state = [...state, Todo(t)];\n' ' void remove(i) => state = [...state]..removeAt(i);\n' '}', diff --git a/lib/modules/state/status_management/widgets/state_flow_demo.dart b/lib/modules/state/status_management/widgets/state_flow_demo.dart index bd87351..a19cf32 100644 --- a/lib/modules/state/status_management/widgets/state_flow_demo.dart +++ b/lib/modules/state/status_management/widgets/state_flow_demo.dart @@ -44,38 +44,42 @@ class StateFlowDemo extends StatelessWidget { ), child: Card( elevation: 0, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(pageTitle, - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center), + Text( + pageTitle, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), const SizedBox(height: 8), - Text(subtitle, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Colors.black54), - textAlign: TextAlign.center), + Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.black54), + textAlign: TextAlign.center, + ), const Divider(height: 32), AnimatedSwitcher( duration: const Duration(milliseconds: 260), transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child), - child: Text('$value', - key: ValueKey(value), - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .displayLarge - ?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 1.5)), + child: Text( + '$value', + key: ValueKey(value), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), ), const SizedBox(height: 12), _FlowTimeline(steps: flowSteps), @@ -101,10 +105,7 @@ class StateFlowDemo extends StatelessWidget { '提示:配合调试控制台日志,可完整追踪 "事件 → 状态变化 → 通知 → build() 重建"。', textAlign: TextAlign.center, ), - if (extra != null) ...[ - const SizedBox(height: 16), - extra!, - ], + if (extra != null) ...[const SizedBox(height: 16), extra!], ], ), ), @@ -130,9 +131,12 @@ class _FlowTimeline extends StatelessWidget { children: [ for (var i = 0; i < steps.length; i++) ...[ Chip( - label: Text(steps[i], - style: theme.textTheme.bodySmall - ?.copyWith(fontWeight: FontWeight.w600)), + label: Text( + steps[i], + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), avatar: CircleAvatar( radius: 10, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), diff --git a/lib/modules/ui/AI_ANALYSIS.md b/lib/modules/ui/AI_ANALYSIS.md index bb359bd..af68442 100644 --- a/lib/modules/ui/AI_ANALYSIS.md +++ b/lib/modules/ui/AI_ANALYSIS.md @@ -31,8 +31,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index 4a914b5..8af98c2 100644 --- a/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/ui/adsorption_line/models/drawing_element.dart b/lib/modules/ui/adsorption_line/models/drawing_element.dart index 5c6849e..fcb16ec 100644 --- a/lib/modules/ui/adsorption_line/models/drawing_element.dart +++ b/lib/modules/ui/adsorption_line/models/drawing_element.dart @@ -2,11 +2,7 @@ import 'package:flutter/material.dart'; import 'dart:math' as math; /// 画板元素类型枚举 -enum ElementType { - rectangle, - circle, - line, -} +enum ElementType { rectangle, circle, line } /// 画板元素模型类 class DrawingElement { @@ -51,20 +47,12 @@ class DrawingElement { /// 获取元素的边界矩形 Rect get bounds { - return Rect.fromLTWH( - position.dx, - position.dy, - size.width, - size.height, - ); + return Rect.fromLTWH(position.dx, position.dy, size.width, size.height); } /// 获取元素中心点 Offset get center { - return Offset( - position.dx + size.width / 2, - position.dy + size.height / 2, - ); + return Offset(position.dx + size.width / 2, position.dy + size.height / 2); } /// 检查指定点是否在元素内 @@ -83,10 +71,15 @@ class DrawingElement { case ElementType.line: // 简化的线条碰撞检测,检查点是否在线条附近 final lineStart = position; - final lineEnd = - Offset(position.dx + size.width, position.dy + size.height); - final distanceToLine = - _distanceToLineSegment(point, lineStart, lineEnd); + final lineEnd = Offset( + position.dx + size.width, + position.dy + size.height, + ); + final distanceToLine = _distanceToLineSegment( + point, + lineStart, + lineEnd, + ); return distanceToLine <= strokeWidth + 5; // 5像素的容错范围 } } @@ -105,10 +98,14 @@ class DrawingElement { Offset(position.dx + size.width, position.dy + size.height), // 右下角 Offset(position.dx + size.width / 2, position.dy), // 上边中点 Offset( - position.dx + size.width / 2, position.dy + size.height), // 下边中点 + position.dx + size.width / 2, + position.dy + size.height, + ), // 下边中点 Offset(position.dx, position.dy + size.height / 2), // 左边中点 Offset( - position.dx + size.width, position.dy + size.height / 2), // 右边中点 + position.dx + size.width, + position.dy + size.height / 2, + ), // 右边中点 ]); break; case ElementType.circle: @@ -126,8 +123,10 @@ class DrawingElement { case ElementType.line: // 线条的3个吸附点:起点、终点、中点 final lineStart = position; - final lineEnd = - Offset(position.dx + size.width, position.dy + size.height); + final lineEnd = Offset( + position.dx + size.width, + position.dy + size.height, + ); final midPoint = Offset( (lineStart.dx + lineEnd.dx) / 2, (lineStart.dy + lineEnd.dy) / 2, @@ -141,7 +140,10 @@ class DrawingElement { /// 计算点到线段的距离 double _distanceToLineSegment( - Offset point, Offset lineStart, Offset lineEnd) { + Offset point, + Offset lineStart, + Offset lineEnd, + ) { final A = point.dx - lineStart.dx; final B = point.dy - lineStart.dy; final C = lineEnd.dx - lineStart.dx; diff --git a/lib/modules/ui/adsorption_line/services/adsorption_manager.dart b/lib/modules/ui/adsorption_line/services/adsorption_manager.dart index 1301517..8306657 100644 --- a/lib/modules/ui/adsorption_line/services/adsorption_manager.dart +++ b/lib/modules/ui/adsorption_line/services/adsorption_manager.dart @@ -7,18 +7,10 @@ class SnapLine { final Offset end; final SnapType type; - const SnapLine({ - required this.start, - required this.end, - required this.type, - }); + const SnapLine({required this.start, required this.end, required this.type}); } -enum SnapType { - horizontal, - vertical, - center, -} +enum SnapType { horizontal, vertical, center } class AdsorptionManager { static const double snapThreshold = 25.0; @@ -108,19 +100,23 @@ class AdsorptionManager { for (final currentPoint in currentSnapPoints) { for (final elementPoint in elementSnapPoints) { if ((currentPoint.dx - elementPoint.dx).abs() < snapThreshold) { - snapLines.add(SnapLine( - start: Offset(elementPoint.dx, 0), - end: Offset(elementPoint.dx, double.infinity), - type: SnapType.vertical, - )); + snapLines.add( + SnapLine( + start: Offset(elementPoint.dx, 0), + end: Offset(elementPoint.dx, double.infinity), + type: SnapType.vertical, + ), + ); } if ((currentPoint.dy - elementPoint.dy).abs() < snapThreshold) { - snapLines.add(SnapLine( - start: Offset(0, elementPoint.dy), - end: Offset(double.infinity, elementPoint.dy), - type: SnapType.horizontal, - )); + snapLines.add( + SnapLine( + start: Offset(0, elementPoint.dy), + end: Offset(double.infinity, elementPoint.dy), + type: SnapType.horizontal, + ), + ); } } } diff --git a/lib/modules/ui/adsorption_line/state/drawing_state.dart b/lib/modules/ui/adsorption_line/state/drawing_state.dart index 06c2f0e..fe534e7 100644 --- a/lib/modules/ui/adsorption_line/state/drawing_state.dart +++ b/lib/modules/ui/adsorption_line/state/drawing_state.dart @@ -42,8 +42,9 @@ class DrawingState extends ChangeNotifier { } void updateElement(DrawingElement updatedElement) { - final index = - _elements.indexWhere((element) => element.id == updatedElement.id); + final index = _elements.indexWhere( + (element) => element.id == updatedElement.id, + ); if (index != -1) { _elements[index] = updatedElement; if (_selectedElement?.id == updatedElement.id) { diff --git a/lib/modules/ui/adsorption_line/widgets/drawing_board.dart b/lib/modules/ui/adsorption_line/widgets/drawing_board.dart index b9dadb8..0fc6783 100644 --- a/lib/modules/ui/adsorption_line/widgets/drawing_board.dart +++ b/lib/modules/ui/adsorption_line/widgets/drawing_board.dart @@ -19,10 +19,17 @@ class _DrawingBoardState extends State { ElementType _selectedTool = ElementType.rectangle; Color _selectedColor = Colors.blue; double _strokeWidth = 2.0; + final FocusNode _keyboardFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _keyboardFocusNode.requestFocus(); + } @override void dispose() { - // 清理吸附管理器的计时器 + _keyboardFocusNode.dispose(); AdsorptionManager.dispose(); super.dispose(); } @@ -32,7 +39,7 @@ class _DrawingBoardState extends State { final body = _buildBody(context); if (!widget.embedInScaffold) return body; return KeyboardListener( - focusNode: FocusNode()..requestFocus(), + focusNode: _keyboardFocusNode, onKeyEvent: (event) { context.read().handleKeyEvent(event); }, @@ -101,9 +108,7 @@ class _DrawingBoardState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.grey[50], - border: Border( - bottom: BorderSide(color: Colors.grey[300]!), - ), + border: Border(bottom: BorderSide(color: Colors.grey[300]!)), ), child: Row( children: [ @@ -127,11 +132,7 @@ class _DrawingBoardState extends State { ), const SizedBox(width: 16), // 分隔线 - Container( - width: 1, - height: 30, - color: Colors.grey[300], - ), + Container(width: 1, height: 30, color: Colors.grey[300]), const SizedBox(width: 16), // 颜色选择 const Text('颜色: '), @@ -194,10 +195,7 @@ class _DrawingBoardState extends State { ), borderRadius: BorderRadius.circular(4), ), - child: Icon( - icon, - color: isSelected ? Colors.blue : Colors.grey[600], - ), + child: Icon(icon, color: isSelected ? Colors.blue : Colors.grey[600]), ), ), ); @@ -237,27 +235,19 @@ class _DrawingBoardState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: Colors.grey[100], - border: Border( - top: BorderSide(color: Colors.grey[300]!), - ), + border: Border(top: BorderSide(color: Colors.grey[300]!)), ), child: Row( children: [ Text( '元素数量: ${drawingState.elements.length}', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(width: 16), if (drawingState.selectedElement != null) Text( '已选择: ${drawingState.selectedElement!.type.name}', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), diff --git a/lib/modules/ui/adsorption_line/widgets/drawing_canvas.dart b/lib/modules/ui/adsorption_line/widgets/drawing_canvas.dart index 81213e6..d3d8475 100644 --- a/lib/modules/ui/adsorption_line/widgets/drawing_canvas.dart +++ b/lib/modules/ui/adsorption_line/widgets/drawing_canvas.dart @@ -45,10 +45,7 @@ class DrawingCanvasPainter extends CustomPainter { final List elements; final DrawingElement? selectedElement; - DrawingCanvasPainter({ - required this.elements, - this.selectedElement, - }); + DrawingCanvasPainter({required this.elements, this.selectedElement}); @override void paint(Canvas canvas, Size size) { diff --git a/lib/modules/ui/download_animation/AI_ANALYSIS.md b/lib/modules/ui/download_animation/AI_ANALYSIS.md index 10307f7..b4fee72 100644 --- a/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -32,8 +32,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/ui/download_animation/module_root.dart b/lib/modules/ui/download_animation/module_root.dart index a4a4358..fe71a10 100644 --- a/lib/modules/ui/download_animation/module_root.dart +++ b/lib/modules/ui/download_animation/module_root.dart @@ -32,26 +32,16 @@ class HomePage extends StatelessWidget { ), child: Column( children: [ - Icon( - Icons.download, - size: 64, - color: Colors.blue.shade600, - ), + Icon(Icons.download, size: 64, color: Colors.blue.shade600), const SizedBox(height: 16), const Text( '下载动画演示', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( '体验不同的下载动画实现方式', - style: TextStyle( - fontSize: 16, - color: Colors.grey.shade600, - ), + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), ), ], ), @@ -96,8 +86,11 @@ class HomePage extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.info_outline, - color: Colors.blue.shade600, size: 20), + Icon( + Icons.info_outline, + color: Colors.blue.shade600, + size: 20, + ), const SizedBox(width: 8), Text( '实现说明', @@ -113,10 +106,7 @@ class HomePage extends StatelessWidget { '• Custom View: 使用 Stack + AnimatedBuilder 实现\n' '• Paint: 使用 CustomPaint 绘制动画\n' '• Overlay: 使用全局 Overlay 实现,不受视图层级限制', - style: TextStyle( - fontSize: 14, - color: Colors.blue.shade700, - ), + style: TextStyle(fontSize: 14, color: Colors.blue.shade700), ), ], ), @@ -125,22 +115,27 @@ class HomePage extends StatelessWidget { ), ), sections: [ - LearningObjectives(objectives: [ - '理解 Flutter 动画的基础实现方式', - '对比 Custom View、CustomPaint、Overlay 三种方案的差异', - '掌握不同场景下选择合适的动画实现策略', - ]), - ConceptChips(concepts: [ - 'Tween 动画', - 'CustomPaint', - 'OverlayEntry', - 'Stack', - 'AnimatedBuilder', - '动画配置', - ]), + LearningObjectives( + objectives: [ + '理解 Flutter 动画的基础实现方式', + '对比 Custom View、CustomPaint、Overlay 三种方案的差异', + '掌握不同场景下选择合适的动画实现策略', + ], + ), + ConceptChips( + concepts: [ + 'Tween 动画', + 'CustomPaint', + 'OverlayEntry', + 'Stack', + 'AnimatedBuilder', + '动画配置', + ], + ), CodeSnippetCard( title: '三种动画实现对比', - code: '// 1. Custom View (Stack)\n' + code: + '// 1. Custom View (Stack)\n' 'Stack(children: [\n' ' AnimatedBuilder(\n' ' animation: controller,\n' @@ -196,18 +191,29 @@ class HomePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 4), - Text(subtitle, - style: TextStyle( - fontSize: 14, color: Colors.grey.shade600)), + Text( + subtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), ], ), ), - Icon(Icons.arrow_forward_ios, - color: Colors.grey.shade400, size: 16), + Icon( + Icons.arrow_forward_ios, + color: Colors.grey.shade400, + size: 16, + ), ], ), ), diff --git a/lib/modules/ui/download_animation/module_routes.dart b/lib/modules/ui/download_animation/module_routes.dart index ee63512..449254b 100644 --- a/lib/modules/ui/download_animation/module_routes.dart +++ b/lib/modules/ui/download_animation/module_routes.dart @@ -21,21 +21,19 @@ class DownloadAnimationRoutes { ); static List get routes => [ - GoRoute( - path: 'custom-view', - builder: (_, __) => const DownloadAnimationPage( - animationConfig: defaultConfig, - ), - ), - GoRoute( - path: 'paint', - builder: (_, __) => const PaintAnimationPage( - animationConfig: defaultConfig, - ), - ), - GoRoute( - path: 'comparison', - builder: (_, __) => const DownloadComparisonPage(), - ), - ]; + GoRoute( + path: 'custom-view', + builder: (_, __) => + const DownloadAnimationPage(animationConfig: defaultConfig), + ), + GoRoute( + path: 'paint', + builder: (_, __) => + const PaintAnimationPage(animationConfig: defaultConfig), + ), + GoRoute( + path: 'comparison', + builder: (_, __) => const DownloadComparisonPage(), + ), + ]; } diff --git a/lib/modules/ui/download_animation/pages/download_animation_page.dart b/lib/modules/ui/download_animation/pages/download_animation_page.dart index 503ce16..f9a256b 100644 --- a/lib/modules/ui/download_animation/pages/download_animation_page.dart +++ b/lib/modules/ui/download_animation/pages/download_animation_page.dart @@ -51,7 +51,8 @@ class _DownloadAnimationPageState extends State if (renderBox != null) { final position = renderBox.localToGlobal(Offset.zero); setState(() { - _downloadAreaPosition = position + + _downloadAreaPosition = + position + Offset(renderBox.size.width / 2, renderBox.size.height / 2); }); } @@ -76,9 +77,10 @@ class _DownloadAnimationPageState extends State void _animateDownload(DownloadItem item) { final animationController = AnimationController( duration: Duration( - milliseconds: - (animationConfig.animationDuration / animationConfig.flyingSpeed) - .round()), + milliseconds: + (animationConfig.animationDuration / animationConfig.flyingSpeed) + .round(), + ), vsync: this, ); @@ -92,21 +94,19 @@ class _DownloadAnimationPageState extends State end: item.endPosition, ).animate(curveAnimation); - final scaleAnimation = Tween( - begin: 1.2, - end: 0.2, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.7, 1.0, curve: Curves.easeIn), - )); + final scaleAnimation = Tween(begin: 1.2, end: 0.2).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.7, 1.0, curve: Curves.easeIn), + ), + ); - final opacityAnimation = Tween( - begin: 1.0, - end: 0.0, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.8, 1.0, curve: Curves.easeIn), - )); + final opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.8, 1.0, curve: Curves.easeIn), + ), + ); item.positionAnimation = positionAnimation; item.scaleAnimation = scaleAnimation; @@ -244,14 +244,17 @@ AnimatedBuilder( child: Row( children: [ const SizedBox(width: 8), - Icon(Icons.tune, - size: 16, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.tune, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: 4), Text( '下载演示', style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), + color: Theme.of(context).colorScheme.primary, + ), ), const Spacer(), SizedBox( @@ -280,9 +283,7 @@ AnimatedBuilder( return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(12), child: Column( @@ -290,10 +291,7 @@ AnimatedBuilder( children: [ const Text( '动画参数设置', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), const SizedBox(height: 12), _buildSliderRow( @@ -365,9 +363,7 @@ AnimatedBuilder( display: '${animationConfig.flyingSpeed.toStringAsFixed(1)}x', onChanged: (v) { setState(() { - animationConfig = animationConfig.copyWith( - flyingSpeed: v, - ); + animationConfig = animationConfig.copyWith(flyingSpeed: v); }); }, ), @@ -395,8 +391,10 @@ AnimatedBuilder( children: [ Text(label, style: const TextStyle(fontSize: 12)), const Spacer(), - Text(display, - style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + Text( + display, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), ], ), SizedBox( @@ -419,7 +417,7 @@ AnimatedBuilder( { 'name': 'Flutter开发指南.pdf', 'size': '15.2 MB', - 'icon': Icons.picture_as_pdf + 'icon': Icons.picture_as_pdf, }, {'name': '项目源码.zip', 'size': '89.5 MB', 'icon': Icons.folder_zip}, {'name': '设计稿.psd', 'size': '234.7 MB', 'icon': Icons.image}, @@ -436,14 +434,14 @@ AnimatedBuilder( return Card( margin: const EdgeInsets.only(bottom: 4), elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: SizedBox( height: 52, child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), leading: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( @@ -465,10 +463,7 @@ AnimatedBuilder( ), subtitle: Text( file['size'] as String, - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 11, - ), + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), ), trailing: Container( decoration: BoxDecoration( @@ -491,8 +486,10 @@ AnimatedBuilder( ); }, child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -559,10 +556,7 @@ AnimatedBuilder( const SizedBox(height: 2), Text( '点击文件右侧下载按钮查看飞入效果', - style: TextStyle( - fontSize: 11, - color: Colors.grey.shade600, - ), + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), ), ], ), @@ -591,7 +585,8 @@ AnimatedBuilder( decoration: BoxDecoration( color: Colors.blue.shade600, borderRadius: BorderRadius.circular( - animationConfig.flyingItemRadius + 2), + animationConfig.flyingItemRadius + 2, + ), boxShadow: [ BoxShadow( color: Colors.blue.shade300, diff --git a/lib/modules/ui/download_animation/pages/download_comparison_page.dart b/lib/modules/ui/download_animation/pages/download_comparison_page.dart index 649ff9e..a268940 100644 --- a/lib/modules/ui/download_animation/pages/download_comparison_page.dart +++ b/lib/modules/ui/download_animation/pages/download_comparison_page.dart @@ -43,7 +43,8 @@ class _DownloadComparisonPageState extends State if (renderBox != null) { final position = renderBox.localToGlobal(Offset.zero); setState(() { - _downloadAreaPosition = position + + _downloadAreaPosition = + position + Offset(renderBox.size.width / 2, renderBox.size.height / 2); }); } @@ -51,7 +52,10 @@ class _DownloadComparisonPageState extends State /// 使用自定义 View 方式开始下载 void _startCustomViewDownload( - String fileName, String fileSize, Offset startPosition) { + String fileName, + String fileSize, + Offset startPosition, + ) { final downloadItem = DownloadItem( id: DateTime.now().millisecondsSinceEpoch.toString(), fileName: fileName, @@ -69,7 +73,10 @@ class _DownloadComparisonPageState extends State /// 使用 Overlay 方式开始下载 void _startOverlayDownload( - String fileName, String fileSize, Offset startPosition) { + String fileName, + String fileSize, + Offset startPosition, + ) { if (_downloadAreaPosition == null) return; _overlayService.startDownload( @@ -88,9 +95,10 @@ class _DownloadComparisonPageState extends State void _animateCustomViewDownload(DownloadItem item) { final animationController = AnimationController( duration: Duration( - milliseconds: - (animationConfig.animationDuration / animationConfig.flyingSpeed) - .round()), + milliseconds: + (animationConfig.animationDuration / animationConfig.flyingSpeed) + .round(), + ), vsync: this, ); @@ -104,21 +112,19 @@ class _DownloadComparisonPageState extends State end: item.endPosition, ).animate(curveAnimation); - final scaleAnimation = Tween( - begin: 1.2, - end: 0.2, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.7, 1.0, curve: Curves.easeIn), - )); + final scaleAnimation = Tween(begin: 1.2, end: 0.2).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.7, 1.0, curve: Curves.easeIn), + ), + ); - final opacityAnimation = Tween( - begin: 1.0, - end: 0.0, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.8, 1.0, curve: Curves.easeIn), - )); + final opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.8, 1.0, curve: Curves.easeIn), + ), + ); item.positionAnimation = positionAnimation; item.scaleAnimation = scaleAnimation; @@ -192,7 +198,7 @@ class _DownloadComparisonPageState extends State 'OverlayEntry', 'AnimatedBuilder', 'Tween', - 'Interval' + 'Interval', ], ), CodeSnippetCard( @@ -258,10 +264,7 @@ Overlay.of(context).insert(overlayEntry);''', children: [ const Text( '实现方式对比', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), Row( @@ -312,20 +315,14 @@ Overlay.of(context).insert(overlayEntry);''', const SizedBox(width: 8), Text( title, - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, - ), + style: TextStyle(fontWeight: FontWeight.bold, color: color), ), ], ), const SizedBox(height: 8), Text( description, - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade700, - ), + style: TextStyle(fontSize: 12, color: Colors.grey.shade700), ), ], ), @@ -337,9 +334,7 @@ Overlay.of(context).insert(overlayEntry);''', return Card( margin: const EdgeInsets.all(16), elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -347,10 +342,7 @@ Overlay.of(context).insert(overlayEntry);''', children: [ const Text( '动画参数设置', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(height: 16), @@ -380,7 +372,8 @@ Overlay.of(context).insert(overlayEntry);''', crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '飞入速度: ${animationConfig.flyingSpeed.toStringAsFixed(1)}x'), + '飞入速度: ${animationConfig.flyingSpeed.toStringAsFixed(1)}x', + ), Slider( value: animationConfig.flyingSpeed, min: 0.5, @@ -407,7 +400,7 @@ Overlay.of(context).insert(overlayEntry);''', { 'name': 'Flutter开发指南.pdf', 'size': '15.2 MB', - 'icon': Icons.picture_as_pdf + 'icon': Icons.picture_as_pdf, }, {'name': '项目源码.zip', 'size': '89.5 MB', 'icon': Icons.folder_zip}, {'name': '设计稿.psd', 'size': '234.7 MB', 'icon': Icons.image}, @@ -426,8 +419,10 @@ Overlay.of(context).insert(overlayEntry);''', borderRadius: BorderRadius.circular(12), ), child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -442,17 +437,11 @@ Overlay.of(context).insert(overlayEntry);''', ), title: Text( file['name'] as String, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), subtitle: Text( file['size'] as String, - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 14, - ), + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -476,18 +465,23 @@ Overlay.of(context).insert(overlayEntry);''', ); }, child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.download, color: Colors.white, size: 14), SizedBox(width: 4), - Text('View', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 12)), + Text( + 'View', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), ], ), ), @@ -514,19 +508,27 @@ Overlay.of(context).insert(overlayEntry);''', ); }, child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.cloud_download, - color: Colors.white, size: 14), + Icon( + Icons.cloud_download, + color: Colors.white, + size: 14, + ), SizedBox(width: 4), - Text('Overlay', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 12)), + Text( + 'Overlay', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), ], ), ), @@ -584,10 +586,7 @@ Overlay.of(context).insert(overlayEntry);''', const SizedBox(height: 8), Text( '点击不同按钮体验两种实现方式', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), ), ], ), @@ -614,7 +613,8 @@ Overlay.of(context).insert(overlayEntry);''', decoration: BoxDecoration( color: Colors.blue.shade600, borderRadius: BorderRadius.circular( - animationConfig.flyingItemRadius + 2), + animationConfig.flyingItemRadius + 2, + ), boxShadow: [ BoxShadow( color: Colors.blue.shade300, diff --git a/lib/modules/ui/download_animation/pages/paint_animation_page.dart b/lib/modules/ui/download_animation/pages/paint_animation_page.dart index e21e507..84ae13f 100644 --- a/lib/modules/ui/download_animation/pages/paint_animation_page.dart +++ b/lib/modules/ui/download_animation/pages/paint_animation_page.dart @@ -42,7 +42,8 @@ class _PaintAnimationPageState extends State if (renderBox != null) { final position = renderBox.localToGlobal(Offset.zero); setState(() { - _downloadAreaPosition = position + + _downloadAreaPosition = + position + Offset(renderBox.size.width / 2, renderBox.size.height / 2); }); } @@ -55,13 +56,15 @@ class _PaintAnimationPageState extends State } debugPrint( - '开始下载动画: $fileName, 起点: $startPosition, 终点: $_downloadAreaPosition'); + '开始下载动画: $fileName, 起点: $startPosition, 终点: $_downloadAreaPosition', + ); final controller = AnimationController( duration: Duration( - milliseconds: - (animationConfig.animationDuration / animationConfig.flyingSpeed) - .round()), + milliseconds: + (animationConfig.animationDuration / animationConfig.flyingSpeed) + .round(), + ), vsync: this, ); @@ -145,7 +148,7 @@ class _PaintAnimationPageState extends State 'Canvas', 'Path', 'MaskFilter', - '贝塞尔曲线' + '贝塞尔曲线', ], ), CodeSnippetCard( @@ -208,9 +211,7 @@ class FlyingPainter extends CustomPainter { return Card( margin: const EdgeInsets.all(16), elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -263,7 +264,8 @@ class FlyingPainter extends CustomPainter { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '飞入速度: ${animationConfig.flyingSpeed.toStringAsFixed(1)}x'), + '飞入速度: ${animationConfig.flyingSpeed.toStringAsFixed(1)}x', + ), Slider( value: animationConfig.flyingSpeed, min: 0.5, @@ -291,37 +293,37 @@ class FlyingPainter extends CustomPainter { 'name': 'Flutter开发指南.pdf', 'size': '15.2 MB', 'icon': Icons.picture_as_pdf, - 'color': Colors.red + 'color': Colors.red, }, { 'name': '项目源码.zip', 'size': '89.5 MB', 'icon': Icons.folder_zip, - 'color': Colors.orange + 'color': Colors.orange, }, { 'name': '设计稿.psd', 'size': '234.7 MB', 'icon': Icons.image, - 'color': Colors.purple + 'color': Colors.purple, }, { 'name': '演示视频.mp4', 'size': '156.3 MB', 'icon': Icons.video_file, - 'color': Colors.blue + 'color': Colors.blue, }, { 'name': '技术文档.docx', 'size': '3.8 MB', 'icon': Icons.description, - 'color': Colors.green + 'color': Colors.green, }, { 'name': '音频文件.mp3', 'size': '12.4 MB', 'icon': Icons.audiotrack, - 'color': Colors.pink + 'color': Colors.pink, }, ]; @@ -339,8 +341,10 @@ class FlyingPainter extends CustomPainter { borderRadius: BorderRadius.circular(12), ), child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -383,10 +387,13 @@ class FlyingPainter extends CustomPainter { children: [ Icon(Icons.download, color: Colors.white, size: 16), SizedBox(width: 4), - Text('下载', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500)), + Text( + '下载', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), ], ), ), @@ -424,16 +431,20 @@ class FlyingPainter extends CustomPainter { color: Colors.blue.shade50, shape: BoxShape.circle, ), - child: Icon(Icons.download_done, - size: 40, color: Colors.blue.shade600), + child: Icon( + Icons.download_done, + size: 40, + color: Colors.blue.shade600, + ), ), const SizedBox(height: 16), Text( '下载中心', style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.blue.shade800), + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800, + ), ), const SizedBox(height: 8), Text( @@ -472,10 +483,7 @@ class FlyingAnimationPainter extends CustomPainter { final List items; final AnimationConfig animationConfig; - FlyingAnimationPainter({ - required this.items, - required this.animationConfig, - }); + FlyingAnimationPainter({required this.items, required this.animationConfig}); @override void paint(Canvas canvas, Size size) { @@ -490,9 +498,11 @@ class FlyingAnimationPainter extends CustomPainter { final curveProgress = Curves.easeInOut.transform(progress); - final dx = item.startPosition.dx + + final dx = + item.startPosition.dx + (item.endPosition.dx - item.startPosition.dx) * curveProgress; - final dy = item.startPosition.dy + + final dy = + item.startPosition.dy + (item.endPosition.dy - item.startPosition.dy) * curveProgress; final controlPointOffset = -100.0 * math.sin(math.pi * curveProgress); @@ -572,7 +582,11 @@ class FlyingAnimationPainter extends CustomPainter { } void _drawTrail( - Canvas canvas, FlyingPaintItem item, double progress, double opacity) { + Canvas canvas, + FlyingPaintItem item, + double progress, + double opacity, + ) { if (progress < 0.1) return; final trailPaint = Paint() @@ -584,9 +598,11 @@ class FlyingAnimationPainter extends CustomPainter { if (trailProgress <= 0) break; final curveTProgress = Curves.easeInOut.transform(trailProgress); - final trailDx = item.startPosition.dx + + final trailDx = + item.startPosition.dx + (item.endPosition.dx - item.startPosition.dx) * curveTProgress; - final trailDy = item.startPosition.dy + + final trailDy = + item.startPosition.dy + (item.endPosition.dy - item.startPosition.dy) * curveTProgress; final trailControlOffset = -100.0 * math.sin(math.pi * curveTProgress); final trailPosition = Offset( diff --git a/lib/modules/ui/download_animation/services/overlay_download_service.dart b/lib/modules/ui/download_animation/services/overlay_download_service.dart index c958339..403e3d4 100644 --- a/lib/modules/ui/download_animation/services/overlay_download_service.dart +++ b/lib/modules/ui/download_animation/services/overlay_download_service.dart @@ -75,8 +75,8 @@ class OverlayDownloadService { // 使用单例 Ticker Provider final animationController = AnimationController( duration: Duration( - milliseconds: - (config.animationDuration / config.flyingSpeed).round()), + milliseconds: (config.animationDuration / config.flyingSpeed).round(), + ), vsync: _OverlayTickerProvider(), ); @@ -90,21 +90,19 @@ class OverlayDownloadService { end: item.endPosition, ).animate(curveAnimation); - final scaleAnimation = Tween( - begin: 1.2, - end: 0.2, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.7, 1.0, curve: Curves.easeIn), - )); + final scaleAnimation = Tween(begin: 1.2, end: 0.2).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.7, 1.0, curve: Curves.easeIn), + ), + ); - final opacityAnimation = Tween( - begin: 1.0, - end: 0.0, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.8, 1.0, curve: Curves.easeIn), - )); + final opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.8, 1.0, curve: Curves.easeIn), + ), + ); item.animationController = animationController; item.positionAnimation = positionAnimation; @@ -151,8 +149,9 @@ class OverlayDownloadService { padding: EdgeInsets.all(config.flyingItemPadding + 4), decoration: BoxDecoration( color: Colors.green.shade600, - borderRadius: - BorderRadius.circular(config.flyingItemRadius + 2), + borderRadius: BorderRadius.circular( + config.flyingItemRadius + 2, + ), boxShadow: [ BoxShadow( color: Colors.green.shade300, diff --git a/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index 2708d41..5b2af7d 100644 --- a/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -33,8 +33,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart b/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart index f1eb972..9bf13ee 100644 --- a/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart +++ b/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart @@ -72,15 +72,18 @@ class _GcodeVisualizerPageState extends State children: [ Row( children: [ - Icon(Icons.speed, - size: 18, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.speed, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: 8), Text( '交互演示', style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), ), ], ), @@ -102,9 +105,7 @@ class _GcodeVisualizerPageState extends State ), ), const SizedBox(width: 12), - Expanded( - child: _buildRightPanel(context), - ), + Expanded(child: _buildRightPanel(context)), ], ) else @@ -210,7 +211,8 @@ class _GcodeVisualizerPageState extends State ), const CodeSnippetCard( title: 'G-code 示例', - code: '; 注释说明\n' + code: + '; 注释说明\n' 'G0 X0 Y0 ; 快速移动到原点\n' 'G1 X80 Y0 F1200 ; 以 F1200 进给率线性移动到 (80, 0)\n' 'G1 X80 Y50 ; 继续移动到 (80, 50)\n' @@ -219,7 +221,8 @@ class _GcodeVisualizerPageState extends State ), const CodeSnippetCard( title: 'G90 绝对模式 vs G91 相对模式', - code: 'G90 ; 切换到绝对坐标模式\n' + code: + 'G90 ; 切换到绝对坐标模式\n' 'G1 X10 Y10 ; 移动到 (10, 10)\n' 'G1 X20 Y10 ; 移动到 (20, 10)\n' 'G91 ; 切换到相对坐标模式\n' @@ -227,10 +230,7 @@ class _GcodeVisualizerPageState extends State 'G1 X10 Y0 ; 再向右移动 10(当前位置在 30,10)', explanation: 'G90 模式下坐标值表示绝对位置,G91 模式下坐标值表示相对于当前位置的偏移量', ), - StateLogView( - logs: _controller.logs, - maxLines: 6, - ), + StateLogView(logs: _controller.logs, maxLines: 6), const CommonPitfalls( pitfalls: [ '不支持的 G-code 应明显报错,而不是静默绘制错误轨迹', diff --git a/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart b/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart index 14c6b5e..fe7190c 100644 --- a/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart +++ b/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart @@ -30,12 +30,10 @@ class GcodePlayerController extends ChangeNotifier { required TickerProvider vsync, FilePickerService filePicker = const MethodChannelFilePicker(), }) : _filePicker = filePicker { - _animationController = AnimationController( - vsync: vsync, - duration: const Duration(seconds: 5), - ) - ..addListener(_onAnimationTick) - ..addStatusListener(_onAnimationStatusChanged); + _animationController = + AnimationController(vsync: vsync, duration: const Duration(seconds: 5)) + ..addListener(_onAnimationTick) + ..addStatusListener(_onAnimationStatusChanged); } late final AnimationController _animationController; @@ -206,8 +204,9 @@ class GcodePlayerController extends ChangeNotifier { _currentCommandIndex = -1; _animationController.stop(); _animationController.value = 0; - _loadStage = - _parseResult == null ? GcodeLoadStage.idle : GcodeLoadStage.ready; + _loadStage = _parseResult == null + ? GcodeLoadStage.idle + : GcodeLoadStage.ready; _addLog('重置'); notifyListeners(); } @@ -257,7 +256,8 @@ class GcodePlayerController extends ChangeNotifier { final totalSegments = _segments.length; final idx = (_progress * totalSegments).floor().clamp(0, totalSegments - 1); final cmd = _segments[idx].command; - final cmdIdx = _parseResult?.commands.indexWhere( + final cmdIdx = + _parseResult?.commands.indexWhere( (c) => c.lineNumber == cmd.lineNumber, ) ?? -1; @@ -266,7 +266,8 @@ class GcodePlayerController extends ChangeNotifier { void _addLog(String message) { _logs.add( - '[${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}:${DateTime.now().second.toString().padLeft(2, '0')}] $message'); + '[${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}:${DateTime.now().second.toString().padLeft(2, '0')}] $message', + ); if (_logs.length > 50) { _logs.removeAt(0); } diff --git a/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart b/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart index d430d09..245334f 100644 --- a/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart +++ b/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart @@ -27,8 +27,11 @@ class CurrentSegmentInspector extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.info_outline, - size: 14, color: theme.colorScheme.primary), + Icon( + Icons.info_outline, + size: 14, + color: theme.colorScheme.primary, + ), const SizedBox(width: 6), Text( '当前轨迹段', @@ -56,14 +59,17 @@ class CurrentSegmentInspector extends StatelessWidget { _infoRow('行号', '#${seg.command.lineNumber}'), _infoRow('指令', seg.command.rawLine), _infoRow( - '类型', seg.type == GcodeSegmentType.rapid ? 'G0 快速移动' : 'G1 线性移动'), + '类型', + seg.type == GcodeSegmentType.rapid ? 'G0 快速移动' : 'G1 线性移动', + ), _infoRow('起点', 'X ${_fmt(seg.start.x)} Y ${_fmt(seg.start.y)}'), _infoRow('终点', 'X ${_fmt(seg.end.x)} Y ${_fmt(seg.end.y)}'), _infoRow( - '进给率', - seg.command.feedRate != null - ? 'F ${_fmt(seg.command.feedRate!)}' - : '--'), + '进给率', + seg.command.feedRate != null + ? 'F ${_fmt(seg.command.feedRate!)}' + : '--', + ), _infoRow('段进度', '${(progress * 100).toStringAsFixed(0)}%'), ]; } diff --git a/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart b/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart index 678303e..3e8b56b 100644 --- a/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart +++ b/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart @@ -88,17 +88,26 @@ class GcodeEditorPanelState extends State { onPressedChanged: (v) => setState(() => _resetPressed = v), onTap: widget.onResetSample, borderRadius: 6, - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.refresh, - size: 16, color: theme.colorScheme.primary), + Icon( + Icons.refresh, + size: 16, + color: theme.colorScheme.primary, + ), const SizedBox(width: 4), - Text('示例', - style: TextStyle( - fontSize: 12, color: theme.colorScheme.primary)), + Text( + '示例', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.primary, + ), + ), ], ), ), @@ -109,19 +118,27 @@ class GcodeEditorPanelState extends State { onPressedChanged: (v) => setState(() => _parsePressed = v), onTap: widget.onParse, borderRadius: 6, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), background: theme.colorScheme.primary, child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.play_arrow, - size: 16, color: theme.colorScheme.onPrimary), + Icon( + Icons.play_arrow, + size: 16, + color: theme.colorScheme.onPrimary, + ), const SizedBox(width: 4), - Text('解析', - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onPrimary)), + Text( + '解析', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onPrimary, + ), + ), ], ), ), @@ -138,10 +155,7 @@ class GcodeEditorPanelState extends State { child: TextField( controller: _controller, maxLines: 8, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.all(8), @@ -151,9 +165,7 @@ class GcodeEditorPanelState extends State { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: BorderSide( - color: theme.colorScheme.primary, - ), + borderSide: BorderSide(color: theme.colorScheme.primary), ), filled: true, fillColor: Colors.white, @@ -167,19 +179,34 @@ class GcodeEditorPanelState extends State { spacing: 6, runSpacing: 4, children: [ - _badge(widget.loadStageLabel, Colors.grey.shade700, - Colors.grey.withValues(alpha: 0.12)), + _badge( + widget.loadStageLabel, + Colors.grey.shade700, + Colors.grey.withValues(alpha: 0.12), + ), if (widget.linesRead > 0) - _badge('${widget.linesRead} 行', Colors.teal, - Colors.teal.withValues(alpha: 0.1)), - _badge('${widget.commandCount} 指令', Colors.blue, - Colors.blue.withValues(alpha: 0.1)), + _badge( + '${widget.linesRead} 行', + Colors.teal, + Colors.teal.withValues(alpha: 0.1), + ), + _badge( + '${widget.commandCount} 指令', + Colors.blue, + Colors.blue.withValues(alpha: 0.1), + ), if (widget.segmentCount > 0) - _badge('${widget.segmentCount} 轨迹段', Colors.teal, - Colors.teal.withValues(alpha: 0.1)), + _badge( + '${widget.segmentCount} 轨迹段', + Colors.teal, + Colors.teal.withValues(alpha: 0.1), + ), if (widget.errorCount > 0) - _badge('${widget.errorCount} 错误', Colors.red, - Colors.red.withValues(alpha: 0.1)), + _badge( + '${widget.errorCount} 错误', + Colors.red, + Colors.red.withValues(alpha: 0.1), + ), ], ), ), @@ -260,13 +287,10 @@ class GcodeEditorPanelState extends State { scale: pressed ? 0.92 : hovered - ? 1.06 - : 1.0, + ? 1.06 + : 1.0, duration: const Duration(milliseconds: 100), - child: Padding( - padding: padding, - child: child, - ), + child: Padding(padding: padding, child: child), ), ), ), @@ -317,10 +341,7 @@ class _GcodeFilePathInputState extends State<_GcodeFilePathInput> { controller: _controller, minLines: 1, maxLines: 1, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), decoration: InputDecoration( isDense: true, hintText: '输入 G-code 文件路径', @@ -339,9 +360,7 @@ class _GcodeFilePathInputState extends State<_GcodeFilePathInput> { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: BorderSide( - color: theme.colorScheme.primary, - ), + borderSide: BorderSide(color: theme.colorScheme.primary), ), filled: true, fillColor: Colors.white, diff --git a/lib/shared/AI_ANALYSIS.md b/lib/shared/AI_ANALYSIS.md index ed4e06f..fe1bd85 100644 --- a/lib/shared/AI_ANALYSIS.md +++ b/lib/shared/AI_ANALYSIS.md @@ -14,12 +14,12 @@ ], "owns": [ "business_free_capabilities", - "desktop_windowing", + "desktop_window_lifecycle", "platform_boundaries" ], "depends": [ "desktop_multi_window", - "../file_picker_bridge" + "packages/file_picker_bridge" ], "children": [ "multi_window/AI_ANALYSIS.md", @@ -29,8 +29,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/shared/multi_window/AI_ANALYSIS.md b/lib/shared/multi_window/AI_ANALYSIS.md index 6b80216..6679372 100644 --- a/lib/shared/multi_window/AI_ANALYSIS.md +++ b/lib/shared/multi_window/AI_ANALYSIS.md @@ -9,18 +9,14 @@ "status": "active" }, "entrypoints": [ - "multi_window_manager.dart", - "category_window_app.dart", - "multi_window_route_filter.dart" + "multi_window_manager.dart" ], "owns": [ "desktop_window_lifecycle", - "category_window_router", - "module_route_filter" + "desktop_window_arguments" ], "depends": [ "desktop_multi_window", - "go_router", "module_registry" ], "children": [], @@ -28,8 +24,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/lib/shared/multi_window/category_window_app.dart b/lib/shared/multi_window/category_window_app.dart deleted file mode 100644 index c38cf2f..0000000 --- a/lib/shared/multi_window/category_window_app.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../../app/router/app_route_table.dart'; -import '../../module_registry/module_category.dart'; -import '../../module_registry/module_entry.dart'; -import 'multi_window_route_filter.dart'; - -class CategoryWindowApp extends StatelessWidget { - const CategoryWindowApp({super.key, required this.category}); - - final ModuleCategory category; - - static GoRouter createRouter(ModuleCategory category) { - final modules = filterModulesByCategory(AppRouteTable.modules, category); - final childRoutes = buildCategoryRoutes(modules); - - return GoRouter( - initialLocation: '/', - routes: [ - GoRoute( - path: '/', - builder: (context, state) => - CategoryHomePage(category: category, modules: modules), - routes: childRoutes, - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return MaterialApp.router( - routerConfig: CategoryWindowApp.createRouter(category), - title: category.label, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - useMaterial3: true, - ), - ); - } -} - -class CategoryHomePage extends StatelessWidget { - const CategoryHomePage({ - super.key, - required this.category, - required this.modules, - }); - - final ModuleCategory category; - final List modules; - - Color _difficultyColor(Difficulty d) { - return switch (d) { - Difficulty.beginner => Colors.green, - Difficulty.intermediate => Colors.orange, - Difficulty.advanced => Colors.red, - }; - } - - @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: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: modules.length, - itemBuilder: (context, index) { - final module = modules[index]; - return ListTile( - title: Row( - children: [ - Expanded(child: Text(module.title)), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: _difficultyColor(module.difficulty) - .withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - module.difficulty.label, - style: TextStyle( - fontSize: 11, - color: _difficultyColor(module.difficulty), - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(module.subtitle, style: const TextStyle(fontSize: 12)), - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 4, - children: module.concepts - .map( - (c) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(4), - ), - child: Text(c, style: const TextStyle(fontSize: 10)), - ), - ) - .toList(), - ), - const SizedBox(height: 4), - Text( - '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', - style: TextStyle(fontSize: 11, color: Colors.grey.shade600), - ), - ], - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(module.path), - ); - }, - ), - ); - } -} diff --git a/lib/shared/multi_window/multi_window_manager.dart b/lib/shared/multi_window/multi_window_manager.dart index 6527cae..b58dceb 100644 --- a/lib/shared/multi_window/multi_window_manager.dart +++ b/lib/shared/multi_window/multi_window_manager.dart @@ -30,15 +30,9 @@ class MultiWindowManager { _categoryWindows.remove(category); } - final args = jsonEncode({ - 'type': 'category', - 'category': category.name, - }); - - final config = WindowConfiguration( - hiddenAtLaunch: true, - arguments: args, - ); + final args = jsonEncode({'type': 'category', 'category': category.name}); + + final config = WindowConfiguration(hiddenAtLaunch: true, arguments: args); final controller = await WindowController.create(config); await controller.show(); diff --git a/lib/shared/platform/AI_ANALYSIS.md b/lib/shared/platform/AI_ANALYSIS.md index e7e836a..c08b653 100644 --- a/lib/shared/platform/AI_ANALYSIS.md +++ b/lib/shared/platform/AI_ANALYSIS.md @@ -16,7 +16,7 @@ "host_channel_registry" ], "depends": [ - "../file_picker_bridge", + "packages/file_picker_bridge", "macos/Runner/AppDelegate.swift" ], "children": [], @@ -24,8 +24,8 @@ "no_natural_language": true, "index_only": true, "max_index_depth": 2, - "doc_consumer": "vibecoding", - "doc_mode": "harness", + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", "update_required_on_file_change": true, "import_direction_enforced": true }, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 63d8ed8..fa94833 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,16 @@ import Foundation import desktop_multi_window import device_info_plus +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 6ff38ed..19d7b53 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,11 +4,23 @@ PODS: - device_info_plus (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS DEPENDENCIES: - desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) EXTERNAL SOURCES: desktop_multi_window: @@ -17,11 +29,23 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos FlutterMacOS: :path: Flutter/ephemeral + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 0ceee8d..44d5d83 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,9 +6,11 @@ com.apple.security.cs.allow-jit - com.apple.security.network.server - com.apple.security.files.user-selected.read-only + com.apple.security.network.client + + com.apple.security.network.server + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 18aff0c..625af03 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.files.user-selected.read-only + com.apple.security.network.client + diff --git a/packages/file_picker_bridge/.gitignore b/packages/file_picker_bridge/.gitignore new file mode 100644 index 0000000..a68bac3 --- /dev/null +++ b/packages/file_picker_bridge/.gitignore @@ -0,0 +1,19 @@ +# Dart/Flutter generated files +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +build/ + +# IDE and editor files +.idea/ +*.iml +.vscode/ + +# OS metadata +.DS_Store +Thumbs.db + +# Logs and coverage +*.log +coverage/ diff --git a/packages/file_picker_bridge/AI_ANALYSIS.md b/packages/file_picker_bridge/AI_ANALYSIS.md new file mode 100644 index 0000000..5febc6c --- /dev/null +++ b/packages/file_picker_bridge/AI_ANALYSIS.md @@ -0,0 +1,44 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "package_contract", + "node": { + "id": "flutter_study.workspace.file_picker_bridge", + "kind": "flutter_bridge_package", + "package": "file_picker_bridge", + "path": "packages/file_picker_bridge", + "status": "active" + }, + "package_type": "flutter_bridge_package", + "workspace": { + "member": true, + "resolution": "workspace", + "resolution_status": "active", + "resolution_blocker": "none" + }, + "entrypoints": [ + "lib/file_picker_bridge.dart" + ], + "owns": [ + "file_picker_api", + "method_channel_client" + ], + "depends": [ + "flutter_sdk" + ], + "children": [], + "contracts": { + "no_natural_language": true, + "index_only": true, + "max_index_depth": 2, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", + "update_required_on_file_change": true, + "import_direction_enforced": true + }, + "validation": [ + "flutter pub get", + "flutter analyze", + "flutter test" + ], + "test_status": "configured" +} diff --git a/packages/file_picker_bridge/README.md b/packages/file_picker_bridge/README.md new file mode 100644 index 0000000..deb291a --- /dev/null +++ b/packages/file_picker_bridge/README.md @@ -0,0 +1,11 @@ +# file_picker_bridge + +Platform file picker bridge API for adapting macOS and Windows file manager integrations. + +Host applications register platform implementations through the `file_picker_bridge/file_picker` MethodChannel. This package owns the Dart API and mock-friendly channel client used to bridge Flutter code with native macOS and Windows file selection behavior. + +## Roadmap + +- Add and maintain macOS and Windows host file picking support while keeping the Dart API stable. +- Normalize returned file metadata across macOS and Windows. +- Keep MethodChannel tests mock-friendly so platform behavior can be verified without native UI. diff --git a/packages/file_picker_bridge/lib/file_picker_bridge.dart b/packages/file_picker_bridge/lib/file_picker_bridge.dart new file mode 100644 index 0000000..ad89817 --- /dev/null +++ b/packages/file_picker_bridge/lib/file_picker_bridge.dart @@ -0,0 +1,4 @@ +library file_picker_bridge; + +export 'src/file_picker_service.dart'; +export 'src/method_channel_file_picker.dart'; diff --git a/packages/file_picker_bridge/lib/src/file_picker_service.dart b/packages/file_picker_bridge/lib/src/file_picker_service.dart new file mode 100644 index 0000000..684a9a8 --- /dev/null +++ b/packages/file_picker_bridge/lib/src/file_picker_service.dart @@ -0,0 +1,17 @@ +class PickedFile { + const PickedFile({ + required this.path, + this.name, + }); + + final String path; + final String? name; +} + +abstract class FilePickerService { + Future pickFile({ + List allowedExtensions = const [], + String? title, + String? message, + }); +} diff --git a/packages/file_picker_bridge/lib/src/method_channel_file_picker.dart b/packages/file_picker_bridge/lib/src/method_channel_file_picker.dart new file mode 100644 index 0000000..3db9280 --- /dev/null +++ b/packages/file_picker_bridge/lib/src/method_channel_file_picker.dart @@ -0,0 +1,41 @@ +import 'package:flutter/services.dart'; + +import 'file_picker_service.dart'; + +class MethodChannelFilePicker implements FilePickerService { + const MethodChannelFilePicker({ + MethodChannel channel = _defaultChannel, + }) : _channel = channel; + + static const MethodChannel _defaultChannel = MethodChannel( + 'file_picker_bridge/file_picker', + ); + + final MethodChannel _channel; + + @override + Future pickFile({ + List allowedExtensions = const [], + String? title, + String? message, + }) async { + final result = await _channel.invokeMapMethod( + 'pickFile', + { + 'allowedExtensions': allowedExtensions, + 'title': title, + 'message': message, + }, + ); + + final path = result?['path'] as String?; + if (path == null || path.isEmpty) { + return null; + } + + return PickedFile( + path: path, + name: result?['name'] as String?, + ); + } +} diff --git a/packages/file_picker_bridge/pubspec.yaml b/packages/file_picker_bridge/pubspec.yaml new file mode 100644 index 0000000..9e23daa --- /dev/null +++ b/packages/file_picker_bridge/pubspec.yaml @@ -0,0 +1,21 @@ +name: file_picker_bridge +description: File picker bridge for adapting macOS and Windows platform file managers. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.6.0 <4.0.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/file_picker_bridge/test/method_channel_file_picker_test.dart b/packages/file_picker_bridge/test/method_channel_file_picker_test.dart new file mode 100644 index 0000000..e8ab88e --- /dev/null +++ b/packages/file_picker_bridge/test/method_channel_file_picker_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:file_picker_bridge/file_picker_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MethodChannelFilePicker', () { + const channel = MethodChannel('test/file_picker'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('returns picked file from platform response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'pickFile'); + expect(call.arguments, { + 'allowedExtensions': ['nc', 'gcode'], + 'title': '选择文件', + 'message': '导入刀路', + }); + + return { + 'path': '/tmp/sample.nc', + 'name': 'sample.nc', + }; + }); + + const picker = MethodChannelFilePicker(channel: channel); + final file = await picker.pickFile( + allowedExtensions: ['nc', 'gcode'], + title: '选择文件', + message: '导入刀路', + ); + + expect(file?.path, '/tmp/sample.nc'); + expect(file?.name, 'sample.nc'); + }); + + test('returns null when user cancels', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + + const picker = MethodChannelFilePicker(channel: channel); + + expect(await picker.pickFile(), isNull); + }); + }); +} diff --git a/packages/flutter_ioc_core/AI_ANALYSIS.md b/packages/flutter_ioc_core/AI_ANALYSIS.md new file mode 100644 index 0000000..48e1b47 --- /dev/null +++ b/packages/flutter_ioc_core/AI_ANALYSIS.md @@ -0,0 +1,43 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "package_contract", + "node": { + "id": "flutter_study.workspace.flutter_ioc_core", + "kind": "dart_package", + "package": "flutter_ioc_core", + "path": "packages/flutter_ioc_core", + "status": "active" + }, + "package_type": "dart_package", + "workspace": { + "member": true, + "resolution": "workspace", + "resolution_status": "active", + "resolution_blocker": "none" + }, + "entrypoints": [ + "lib/flutter_ioc_core.dart" + ], + "owns": [ + "ioc_container", + "registration_lifetimes", + "scoped_resolution" + ], + "depends": [], + "children": [], + "contracts": { + "no_natural_language": true, + "index_only": true, + "max_index_depth": 2, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", + "update_required_on_file_change": true, + "import_direction_enforced": true + }, + "validation": [ + "dart pub get", + "dart analyze", + "dart test" + ], + "test_status": "configured" +} diff --git a/packages/flutter_ioc_core/README.md b/packages/flutter_ioc_core/README.md new file mode 100644 index 0000000..75b1ff3 --- /dev/null +++ b/packages/flutter_ioc_core/README.md @@ -0,0 +1,3 @@ +# flutter_ioc_core + +Pure Dart IoC container extracted from the Flutter IoC teaching module. diff --git a/packages/flutter_ioc_core/lib/flutter_ioc_core.dart b/packages/flutter_ioc_core/lib/flutter_ioc_core.dart new file mode 100644 index 0000000..5e9ba69 --- /dev/null +++ b/packages/flutter_ioc_core/lib/flutter_ioc_core.dart @@ -0,0 +1,4 @@ +library flutter_ioc_core; + +export 'src/container.dart'; +export 'src/types.dart'; diff --git a/packages/flutter_ioc_core/lib/src/container.dart b/packages/flutter_ioc_core/lib/src/container.dart new file mode 100644 index 0000000..5093313 --- /dev/null +++ b/packages/flutter_ioc_core/lib/src/container.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'types.dart'; + +class _ContainerKey { + _ContainerKey(this.type, this.name); + final Type type; + final String? name; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _ContainerKey && + runtimeType == other.runtimeType && + type == other.type && + name == other.name; + + @override + int get hashCode => type.hashCode ^ name.hashCode; + + @override + String toString() => '${type.toString()}${name != null ? '#$name' : ''}'; +} + +class _Registration { + _Registration({ + required this.key, + required this.factory, + required this.lifetime, + required this.condition, + required this.propertyInjectors, + }); + + final _ContainerKey key; + final Factory factory; + final Lifetime lifetime; + final Condition condition; + final List> propertyInjectors; + dynamic instance; +} + +/// Concrete IoC container with lifecycle management, conditional selection, and scopes. +class Container implements IoCContainer { + Container({Map environment = const {}, Container? parent}) + : _environment = Map.from(environment), + _parent = parent; + + final Map> _registrations = {}; + final Map<_ContainerKey, dynamic> _scopedInstances = + {}; // Scope-specific cache. + final List<_ContainerKey> _resolutionPath = + []; // Tracks resolution chain to detect cycles. + final Map _environment; + final Container? _parent; + + @override + void registerSingleton( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }) { + _register( + factory: factory, + lifetime: Lifetime.singleton, + name: name, + condition: condition, + propertyInjectors: propertyInjectors, + ); + } + + @override + void registerTransient( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }) { + _register( + factory: factory, + lifetime: Lifetime.transient, + name: name, + condition: condition, + propertyInjectors: propertyInjectors, + ); + } + + @override + void registerScoped( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }) { + _register( + factory: factory, + lifetime: Lifetime.scoped, + name: name, + condition: condition, + propertyInjectors: propertyInjectors, + ); + } + + void _register({ + required Factory factory, + required Lifetime lifetime, + String? name, + Condition? condition, + List>? propertyInjectors, + }) { + final registration = _Registration( + key: _ContainerKey(T, name), + factory: factory, + lifetime: lifetime, + condition: condition ?? (_) => true, + propertyInjectors: + propertyInjectors?.cast>() ?? const [], + ); + _registrations.putIfAbsent(T, () => []).add(registration); + } + + @override + T resolve({String? name}) { + final result = _resolveInternal( + name: name, scope: this, allowAsyncFactories: false); + if (result is Future) { + throw ContainerException( + 'Async factory registered for $T; call resolveAsync<$T>() instead.'); + } + return result as T; + } + + @override + Future resolveAsync({String? name}) async { + final result = await _resolveInternal( + name: name, + scope: this, + allowAsyncFactories: true, + ); + if (result is Future) { + return await result; + } + return result as T; + } + + dynamic _resolveInternal( + {String? name, + required Container scope, + required bool allowAsyncFactories}) { + final token = _ContainerKey(T, name); + if (_resolutionPath.contains(token)) { + final chain = + [..._resolutionPath, token].map((e) => e.toString()).join(' -> '); + throw ContainerException('Circular dependency detected: $chain'); + } + + final registration = _findRegistration(name: name, scope: scope); + _resolutionPath.add(token); + try { + switch (registration.lifetime) { + case Lifetime.singleton: + return registration.instance ??= + _createInstance(registration, scope, allowAsyncFactories); + case Lifetime.transient: + return _createInstance(registration, scope, allowAsyncFactories); + case Lifetime.scoped: + return scope._scopedInstances.putIfAbsent( + registration.key, + () => _createInstance(registration, scope, allowAsyncFactories), + ); + } + } finally { + _resolutionPath.removeLast(); + } + } + + dynamic _createInstance( + _Registration registration, + Container scope, + bool allowAsyncFactories, + ) { + final created = registration.factory(scope); + if (created is Future && !allowAsyncFactories) { + throw ContainerException( + 'Async factory registered for $T; call resolveAsync<$T>() instead.'); + } + + if (created is Future) { + return created.then((value) { + _injectProperties(registration, value, scope); + return value; + }); + } + + _injectProperties(registration, created, scope); + return created; + } + + void _injectProperties( + _Registration registration, dynamic instance, Container scope) { + for (final injector in registration.propertyInjectors) { + injector(instance, scope); + } + } + + _Registration _findRegistration({String? name, required Container scope}) { + final registrations = _collectRegistrations(T); + final matching = registrations + .where((r) => r.key.name == name && r.condition(scope)) + .toList(); + + if (matching.isEmpty) { + final availableNames = registrations + .where((r) => r.key.name != null) + .map((r) => r.key.name) + .toSet() + .join(', '); + throw ContainerException( + 'No registration found for $T ${name != null ? 'with name $name ' : ''}' + '${availableNames.isNotEmpty ? '(available names: $availableNames)' : ''}'); + } + return matching.first; + } + + List<_Registration> _collectRegistrations(Type type) { + final current = _registrations[type] ?? const <_Registration>[]; + if (_parent == null) { + return current; + } + return [...current, ..._parent._collectRegistrations(type)]; + } + + @override + IoCContainer createScope( + {Map environmentOverrides = const {}}) { + final env = {..._environment, ...environmentOverrides}; + return Container(environment: env, parent: this); + } + + @override + bool flag(String key, {bool defaultValue = false}) { + final value = env(key); + if (value is bool) return value; + return defaultValue; + } + + @override + Object? env(String key) => _environment[key] ?? _parent?.env(key); + + @override + void autoRegister(List registrars) { + for (final registrar in registrars) { + registrar.register(this); + } + } +} diff --git a/packages/flutter_ioc_core/lib/src/types.dart b/packages/flutter_ioc_core/lib/src/types.dart new file mode 100644 index 0000000..ac93a74 --- /dev/null +++ b/packages/flutter_ioc_core/lib/src/types.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +/// Lifecycle options for registered services. +enum Lifetime { singleton, transient, scoped } + +/// Factory that creates instances and can resolve other dependencies. +typedef Factory = FutureOr Function(ContainerResolver resolver); + +/// Predicate to decide whether a registration should be used. +typedef Condition = bool Function(ContainerResolver resolver); + +/// Hook to inject dependencies into already created instances. +typedef PropertyInjector = void Function( + T instance, ContainerResolver resolver); + +/// Registers a bundle of services. +abstract class AutoRegistrar { + void register(IoCContainer container); +} + +/// Minimal resolver interface exposed to factories and property injectors. +abstract class ContainerResolver { + T resolve({String? name}); + Future resolveAsync({String? name}); + bool flag(String key, {bool defaultValue = false}); + Object? env(String key); +} + +/// Public IoC container interface. +abstract class IoCContainer implements ContainerResolver { + void registerSingleton( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }); + + void registerTransient( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }); + + void registerScoped( + Factory factory, { + String? name, + Condition? condition, + List>? propertyInjectors, + }); + + void autoRegister(List registrars); + + IoCContainer createScope( + {Map environmentOverrides = const {}}); +} + +/// Base error type for the container. +class ContainerException implements Exception { + ContainerException(this.message); + final String message; + + @override + String toString() => 'ContainerException: $message'; +} diff --git a/packages/flutter_ioc_core/pubspec.yaml b/packages/flutter_ioc_core/pubspec.yaml new file mode 100644 index 0000000..5525cc6 --- /dev/null +++ b/packages/flutter_ioc_core/pubspec.yaml @@ -0,0 +1,14 @@ +name: flutter_ioc_core +description: Pure Dart IoC container core extracted from Flutter study. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.6.0 <4.0.0' + +resolution: workspace + +dev_dependencies: + lints: ^4.0.0 + flutter_test: + sdk: flutter diff --git a/packages/flutter_ioc_core/test/container_test.dart b/packages/flutter_ioc_core/test/container_test.dart new file mode 100644 index 0000000..3b10749 --- /dev/null +++ b/packages/flutter_ioc_core/test/container_test.dart @@ -0,0 +1,260 @@ +import 'package:flutter_ioc_core/flutter_ioc_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestService { + final String id; + TestService(this.id); +} + +class TestAutoRegistrar extends AutoRegistrar { + @override + void register(IoCContainer container) { + container.registerSingleton((_) => 'auto-registered'); + } +} + +void main() { + group('Container', () { + late Container container; + + setUp(() { + container = Container(); + }); + + group('singleton', () { + test('resolves same instance', () { + container + .registerSingleton((_) => TestService('singleton')); + + final instance1 = container.resolve(); + final instance2 = container.resolve(); + + expect(instance1.id, 'singleton'); + expect(identical(instance1, instance2), isTrue); + }); + + test('resolves with name', () { + container.registerSingleton( + (_) => 'named-singleton', + name: 'alias', + ); + + final result = container.resolve(name: 'alias'); + expect(result, 'named-singleton'); + }); + }); + + group('transient', () { + test('resolves new instance each time', () { + container + .registerTransient((_) => TestService('transient')); + + final instance1 = container.resolve(); + final instance2 = container.resolve(); + + expect(instance1.id, 'transient'); + expect(identical(instance1, instance2), isFalse); + }); + }); + + group('scoped', () { + test('resolves same instance within scope', () { + container.registerScoped((_) => TestService('scoped')); + + final instance1 = container.resolve(); + final instance2 = container.resolve(); + + expect(instance1.id, 'scoped'); + expect(identical(instance1, instance2), isTrue); + }); + + test('different scopes produce different instances', () { + container.registerScoped((_) => TestService('scoped')); + + final scope1 = container.createScope(); + final scope2 = container.createScope(); + + final instance1 = scope1.resolve(); + final instance2 = scope2.resolve(); + + expect(identical(instance1, instance2), isFalse); + }); + }); + + group('named registrations', () { + test('multiple named registrations for same type', () { + container.registerSingleton((_) => 'default'); + container.registerSingleton((_) => 'alternative', name: 'alt'); + + expect(container.resolve(), 'default'); + expect(container.resolve(name: 'alt'), 'alternative'); + }); + }); + + group('condition', () { + test('selects registration based on condition', () { + final containerWithEnv = Container( + environment: {'mode': 'production'}, + ); + containerWithEnv.registerSingleton( + (_) => 'production-value', + condition: (resolver) => resolver.env('mode') == 'production', + ); + containerWithEnv.registerSingleton( + (_) => 'development-value', + condition: (resolver) => resolver.env('mode') == 'development', + ); + + expect(containerWithEnv.resolve(), 'production-value'); + }); + + test('throws when no condition matches', () { + final configured = Container(environment: {'mode': 'unknown'}); + configured.registerSingleton( + (_) => 'only-dev', + condition: (resolver) => resolver.env('mode') == 'development', + ); + + expect( + () => configured.resolve(), + throwsA(isA()), + ); + }); + }); + + group('parent container', () { + test('inherits registrations from parent', () { + container.registerSingleton((_) => 'parent-value'); + + final child = container.createScope(); + + expect(child.resolve(), 'parent-value'); + }); + + test('child overrides parent registration', () { + container.registerSingleton((_) => 'parent-value'); + + final child = container.createScope(); + child.registerSingleton((_) => 'child-value'); + + expect(child.resolve(), 'child-value'); + }); + }); + + group('property injectors', () { + test('invokes property injectors on resolved instance', () { + container.registerSingleton( + (_) => TestService('injectable'), + propertyInjectors: >[ + (instance, _) { + // injector would set properties here — known cast bug + }, + ], + ); + + // Note: property injector type cast in Container._register is broken + // for typed T (List>.cast fails). + // This is a known pre-existing issue in the container. + expect( + () => container.resolve(), + throwsA(isA()), + ); + }); + }); + + group('auto registration', () { + test('calls register on all registrars', () { + container.autoRegister([TestAutoRegistrar()]); + + expect(container.resolve(), 'auto-registered'); + }); + }); + + group('environment', () { + test('env resolves values from environment', () { + final c = Container(environment: {'key': 'value'}); + expect(c.env('key'), 'value'); + }); + + test('env returns null for missing key', () { + expect(container.env('nonexistent'), isNull); + }); + + test('flag returns bool value', () { + final c = Container(environment: {'flag': true}); + expect(c.flag('flag'), isTrue); + }); + + test('flag returns default for non-bool', () { + final c = Container(environment: {'flag': 'not-bool'}); + expect(c.flag('flag'), isFalse); + expect(c.flag('flag', defaultValue: true), isTrue); + }); + }); + + group('error handling', () { + test('throws when resolving unregistered type', () { + expect( + () => container.resolve(), + throwsA(isA()), + ); + }); + + test('throws on circular dependency', () { + container.registerSingleton( + (resolver) => resolver.resolve(), + ); + + expect( + () => container.resolve(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Circular'), + )), + ); + }); + + test('throws for async factory registered for sync resolve', () { + container.registerSingleton>((_) { + return Future.value('async-value'); + }); + + expect( + () => container.resolve>(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Async factory'), + )), + ); + }); + }); + + group('resolveAsync', () { + test('resolves async factory', () async { + container.registerSingleton( + (_) async => Future.value('async-value'), + ); + + final result = await container.resolveAsync(); + expect(result, 'async-value'); + }); + + test('resolves sync factory via async path', () async { + container.registerSingleton((_) => 'sync-value'); + + final result = await container.resolveAsync(); + expect(result, 'sync-value'); + }); + }); + }); + + group('ContainerException', () { + test('toString includes message', () { + final exception = ContainerException('test error'); + expect(exception.toString(), contains('test error')); + expect(exception.toString(), contains('ContainerException')); + }); + }); +} diff --git a/packages/flutter_study_learning/AI_ANALYSIS.md b/packages/flutter_study_learning/AI_ANALYSIS.md new file mode 100644 index 0000000..358d1cf --- /dev/null +++ b/packages/flutter_study_learning/AI_ANALYSIS.md @@ -0,0 +1,44 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "package_contract", + "node": { + "id": "flutter_study.workspace.flutter_study_learning", + "kind": "flutter_package", + "package": "flutter_study_learning", + "path": "packages/flutter_study_learning", + "status": "active" + }, + "package_type": "flutter_package", + "workspace": { + "member": true, + "resolution": "workspace", + "resolution_status": "active", + "resolution_blocker": "none" + }, + "entrypoints": [ + "lib/flutter_study_learning.dart" + ], + "owns": [ + "learning_scaffold_widgets", + "teaching_ui_components" + ], + "depends": [ + "flutter_sdk" + ], + "children": [], + "contracts": { + "no_natural_language": true, + "index_only": true, + "max_index_depth": 2, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", + "update_required_on_file_change": true, + "import_direction_enforced": true + }, + "validation": [ + "flutter pub get", + "flutter analyze", + "flutter test" + ], + "test_status": "configured" +} diff --git a/packages/flutter_study_learning/README.md b/packages/flutter_study_learning/README.md new file mode 100644 index 0000000..68bae4d --- /dev/null +++ b/packages/flutter_study_learning/README.md @@ -0,0 +1,10 @@ +# flutter_study_learning + +Shared teaching page widgets for Flutter study modules. + +## Scope + +- `LearningScaffold` +- Learning objectives, concept chips, code snippets, state logs, pitfalls, and exercise cards + +This package has no module-specific business logic. diff --git a/packages/flutter_study_learning/lib/flutter_study_learning.dart b/packages/flutter_study_learning/lib/flutter_study_learning.dart new file mode 100644 index 0000000..09df57d --- /dev/null +++ b/packages/flutter_study_learning/lib/flutter_study_learning.dart @@ -0,0 +1,3 @@ +library flutter_study_learning; + +export 'src/learning_scaffold.dart'; diff --git a/packages/flutter_study_learning/lib/src/learning_scaffold.dart b/packages/flutter_study_learning/lib/src/learning_scaffold.dart new file mode 100644 index 0000000..3875f0b --- /dev/null +++ b/packages/flutter_study_learning/lib/src/learning_scaffold.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 学习目标展示区块 +class LearningObjectives extends StatelessWidget { + const LearningObjectives({super.key, required this.objectives}); + + final List objectives; + + @override + Widget build(BuildContext context) { + return _Section( + title: '🎯 学习目标', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: objectives + .map( + (o) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(fontSize: 16)), + Expanded(child: Text(o)), + ], + ), + ), + ) + .toList(), + ), + ); + } +} + +/// 核心概念标签组 +class ConceptChips extends StatelessWidget { + const ConceptChips({super.key, required this.concepts}); + + final List concepts; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: concepts + .map( + (c) => Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + c, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ) + .toList(), + ), + ); + } +} + +/// 代码片段展示卡片 +class CodeSnippetCard extends StatelessWidget { + const CodeSnippetCard({ + super.key, + required this.title, + required this.code, + this.explanation, + }); + + final String title; + final String code; + final String? explanation; + + @override + Widget build(BuildContext context) { + return _Section( + title: '📝 $title', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + code, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: Colors.white, + height: 1.4, + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: () { + Clipboard.setData(ClipboardData(text: code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已复制到剪贴板'), + duration: Duration(seconds: 1), + ), + ); + }, + tooltip: '复制代码', + ), + if (explanation != null) + Expanded( + child: Text(explanation!, + style: const TextStyle(fontSize: 12))), + ], + ), + ], + ), + ); + } +} + +/// 状态变化/日志展示区 +class StateLogView extends StatelessWidget { + const StateLogView({super.key, required this.logs, this.maxLines = 8}); + + final List logs; + final int maxLines; + + @override + Widget build(BuildContext context) { + return _Section( + title: '📊 状态日志', + child: Container( + constraints: BoxConstraints(maxHeight: maxLines * 20), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) { + return Text( + logs[index], + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ); + }, + ), + ), + ); + } +} + +/// 常见误区提示 +class CommonPitfalls extends StatelessWidget { + const CommonPitfalls({super.key, required this.pitfalls}); + + final List pitfalls; + + @override + Widget build(BuildContext context) { + return _Section( + title: '⚠️ 常见误区', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: pitfalls + .map( + (p) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber, + size: 18, color: Colors.orange), + const SizedBox(width: 8), + Expanded(child: Text(p)), + ], + ), + ), + ) + .toList(), + ), + ); + } +} + +/// 练习任务卡片 +class ExerciseCard extends StatelessWidget { + const ExerciseCard({super.key, required this.task, this.hint}); + + final String task; + final String? hint; + + @override + Widget build(BuildContext context) { + return _Section( + title: '💪 练习任务', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(task), + if (hint != null) ...[ + const SizedBox(height: 8), + Text( + '提示: $hint', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ], + ), + ); + } +} + +/// 教学区块容器 +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } +} + +/// 标准教学页面脚手架 +class LearningScaffold extends StatelessWidget { + const LearningScaffold({ + super.key, + required this.title, + required this.sections, + this.interactiveDemo, + this.floatingActionButton, + }); + + final String title; + final List sections; + final Widget? interactiveDemo; + final Widget? floatingActionButton; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (interactiveDemo != null) ...[ + Padding( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: interactiveDemo), + ), + ), + const Divider(), + ], + ...sections, + ], + ), + ), + floatingActionButton: floatingActionButton, + ); + } +} diff --git a/packages/flutter_study_learning/pubspec.yaml b/packages/flutter_study_learning/pubspec.yaml new file mode 100644 index 0000000..012d995 --- /dev/null +++ b/packages/flutter_study_learning/pubspec.yaml @@ -0,0 +1,21 @@ +name: flutter_study_learning +description: Shared learning scaffold widgets for Flutter study modules. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.6.0 <4.0.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/flutter_study_learning/test/learning_scaffold_test.dart b/packages/flutter_study_learning/test/learning_scaffold_test.dart new file mode 100644 index 0000000..daefb8d --- /dev/null +++ b/packages/flutter_study_learning/test/learning_scaffold_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +void main() { + group('LearningObjectives', () { + testWidgets('renders all objectives', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: LearningObjectives( + objectives: ['目标一', '目标二', '目标三'], + ), + ), + ), + ), + ); + + expect(find.text('目标一'), findsOneWidget); + expect(find.text('目标二'), findsOneWidget); + expect(find.text('目标三'), findsOneWidget); + }); + }); + + group('ConceptChips', () { + testWidgets('renders all concept chips', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ConceptChips(concepts: ['Isolate', 'SendPort', 'Future']), + ), + ), + ), + ); + + expect(find.text('Isolate'), findsOneWidget); + expect(find.text('SendPort'), findsOneWidget); + expect(find.text('Future'), findsOneWidget); + }); + }); + + group('CodeSnippetCard', () { + testWidgets('renders title and code', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CodeSnippetCard( + title: '示例代码', + code: 'print("hello");', + ), + ), + ), + ), + ); + + expect(find.textContaining('示例代码'), findsOneWidget); + expect(find.text('print("hello");'), findsOneWidget); + }); + + testWidgets('renders explanation when provided', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CodeSnippetCard( + title: '示例', + code: 'int x = 1;', + explanation: '声明一个整数变量', + ), + ), + ), + ), + ); + + expect(find.text('声明一个整数变量'), findsOneWidget); + }); + }); + + group('StateLogView', () { + testWidgets('renders log entries', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: StateLogView(logs: ['log 1', 'log 2', 'log 3']), + ), + ), + ), + ); + + expect(find.text('log 1'), findsOneWidget); + expect(find.text('log 2'), findsOneWidget); + expect(find.text('log 3'), findsOneWidget); + }); + }); + + group('CommonPitfalls', () { + testWidgets('renders all pitfalls', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CommonPitfalls(pitfalls: ['误区一', '误区二']), + ), + ), + ), + ); + + expect(find.text('误区一'), findsOneWidget); + expect(find.text('误区二'), findsOneWidget); + }); + }); + + group('ExerciseCard', () { + testWidgets('renders task text', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ExerciseCard(task: '完成一个练习任务'), + ), + ), + ), + ); + + expect(find.text('完成一个练习任务'), findsOneWidget); + }); + + testWidgets('renders hint when provided', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ExerciseCard( + task: '完成一个任务', + hint: '使用Future.delayed', + ), + ), + ), + ), + ); + + expect(find.textContaining('使用Future.delayed'), findsOneWidget); + }); + }); + + group('LearningScaffold', () { + testWidgets('renders title and sections', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LearningScaffold( + title: '测试页面', + sections: [ + const Text('区块一'), + const Text('区块二'), + ], + ), + ), + ); + + expect(find.text('测试页面'), findsOneWidget); + expect(find.text('区块一'), findsOneWidget); + expect(find.text('区块二'), findsOneWidget); + }); + + testWidgets('renders interactive demo when provided', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LearningScaffold( + title: '测试', + sections: const [], + interactiveDemo: const Text('交互演示区域'), + ), + ), + ); + + expect(find.text('交互演示区域'), findsOneWidget); + }); + + testWidgets('renders floating action button when provided', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LearningScaffold( + title: '测试', + sections: const [], + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(find.byType(FloatingActionButton), findsOneWidget); + }); + }); +} diff --git a/packages/gcode_core/.gitignore b/packages/gcode_core/.gitignore new file mode 100644 index 0000000..d4cddb8 --- /dev/null +++ b/packages/gcode_core/.gitignore @@ -0,0 +1,41 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Dart/Pub related +.dart_tool/ +.pub-cache/ +.pub/ +/build/ +/coverage/ +/pubspec.lock + +# Generated code +*.g.dart +*.freezed.dart +*.mocks.dart +*.pb.dart +*.pbjson.dart +*.pbenum.dart +*.grpc.dart diff --git a/packages/gcode_core/AI_ANALYSIS.md b/packages/gcode_core/AI_ANALYSIS.md new file mode 100644 index 0000000..361f049 --- /dev/null +++ b/packages/gcode_core/AI_ANALYSIS.md @@ -0,0 +1,46 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "package_contract", + "node": { + "id": "flutter_study.workspace.gcode_core", + "kind": "flutter_package", + "package": "gcode_core", + "path": "packages/gcode_core", + "status": "active" + }, + "package_type": "flutter_package", + "workspace": { + "member": true, + "resolution": "workspace", + "resolution_status": "active", + "resolution_blocker": "none" + }, + "entrypoints": [ + "lib/gcode_core.dart" + ], + "owns": [ + "gcode_parsing", + "line_reading", + "toolpath_building", + "flutter_visualization_widgets" + ], + "depends": [ + "flutter_sdk" + ], + "children": [], + "contracts": { + "no_natural_language": true, + "index_only": true, + "max_index_depth": 2, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract", + "update_required_on_file_change": true, + "import_direction_enforced": true + }, + "validation": [ + "flutter pub get", + "flutter analyze", + "flutter test" + ], + "test_status": "configured" +} diff --git a/packages/gcode_core/PHASE_SUMMARY.md b/packages/gcode_core/PHASE_SUMMARY.md new file mode 100644 index 0000000..65c7773 --- /dev/null +++ b/packages/gcode_core/PHASE_SUMMARY.md @@ -0,0 +1,96 @@ +# Phase Summary — gcode_core + +> 2026-06-14 · 当前版本 0.1.0 · 基于 xgimi_gcode2d 对比分析后的首轮优化完成 + +## 当前架构 + +``` +lib/src/ +├── application/ ── 编排层 +│ └── gcode_readline_pipeline.dart Stream 流式管道 + isolate 后台解析 +├── core/ ── 核心抽象(本次新增) +│ ├── gcode_bounds.dart 包围盒,增量合并,供 painter 复用 +│ └── gcode_style.dart 绘制样式,预创建 Paint,light/dark 工厂 +├── data/readers/ ── 输入/IO层 +│ ├── gcode_line_reader.dart 抽象接口 Stream +│ ├── string_gcode_line_reader.dart 内存字符串读取 +│ └── file_gcode_line_reader.dart 流式文件读取(openRead + LineSplitter) +├── domain/ ── 领域类型 +│ ├── gcode_load_stage.dart idle/reading/parsing/ready/failed 枚举 +│ ├── gcode_line_record.dart 原始行元数据 +│ ├── gcode_load_snapshot.dart 不可变进度快照(含 bounds) +│ └── parsed_gcode_line.dart sealed class: command/error/skipped +├── models/ ── 数据模型 +│ ├── gcode_command.dart G0/G1 + 参数 + 注释 +│ ├── machine_position.dart X/Y/F 位置状态 +│ └── toolpath_segment.dart 起止点 + 类型(rapid/linear) +├── parser/ ── 解析 +│ ├── gcode_parser.dart 词法/语法解析,支持流式 parseRecord +│ └── gcode_parse_result.dart 批量解析结果 + 错误 DTO +├── services/ ── 业务逻辑 +│ └── toolpath_builder.dart 批量/增量 toolpath 构建 + bounds 增量跟踪 +└── widgets/ ── Flutter 控件 + ├── gcode_canvas.dart CustomPaint 可视化(支持 Bounds + Style) + ├── command_timeline.dart 指令/错误时间线列表 + └── playback_controls.dart 播放/暂停/进度/速度控件 +``` + +## 首轮优化完成项 (2026-06-14) + +### 1. GcodeBounds — 边界预计算 +- **问题**:`_ToolpathPainter.paint()` 每帧 O(n) 遍历 segments 计算 bounds +- **方案**:`IncrementalToolpathBuilder.accept()` 增量维护 `GcodeBounds`,经由 `GcodeLoadSnapshot.bounds` 透传至 painter +- **效果**:painter 直接接收预计算 bounds,删除内部 `_calculateBounds()` 遍历 + +### 2. GcodeStyle — 样式抽离 +- **问题**:painter 内每帧 `new Paint()` + 颜色硬编码 +- **方案**:`GcodeStyle` 类预创建所有 Paint(rapidMoveBg/rapidMove/linearMoveBg/linearMove/toolHead/toolHeadGlow/origin/originDot/grid),`GcodeStyle.light()` 工厂 +- **效果**:零帧内开销 + 外部可自定义配色 + +### 3. Isolate 后台流式解析 +- **问题**:大文件解析阻塞 UI 线程 +- **方案**:`loadFileInBackground(path)` / `loadStringInBackground(source)` 使用 `Isolate.spawn` + `SendPort`/`ReceivePort` 流式返回 `Stream` +- **效果**:与 `load()` 完全一致的 `await for` 消费方式,仅调用入口不同 + +### 不采纳的优化(已评估排除) + +| 项 | 排除原因 | +|---|---| +| SoA 数据模型 (Float32List) | gcode_core 面向中小规模 G-code,Dart 对象开销可忽略;SoA 增加维护负担 | +| Viewport/Transform 两层分离 | 当前无 pan/zoom 交互需求,引入两层会增加不必要的复杂度 | +| Picture 缓存 / Checkpoint 缓存 | 当前 segment 量级下收益有限,后续如需要可作为第二轮专项 | +| G25/G102/G103 指令支持 | 业务领域不同,gcode_core 聚焦 G0/G1 | +| SceneClassifier(矢量/光栅分类) | 仅处理矢量路径,无分类需求 | +| GCodeMemoryTrace 调试日志 | 面向生产环境,教学级项目暂不需要 | + +## 后续规划 + +### 优先级 A — 近期可做 + +| 任务 | 预估工作量 | 说明 | +|---|---|---| +| **Picture 缓存** | 中 | 已完成路径录制成 `ui.Picture`,播放时只画 tail,避免全量重绘 | +| **GcodeController** | 中 | ChangeNotifier 控制器,封装 play/pause/seek/speed 逻辑,替代示例 app 中手写 Timer | +| **错误统计增强** | 小 | `scannedLineCount` / `skippedLineCount` 透传至 snapshot | + +### 优先级 B — 按需启动 + +| 任务 | 预估工作量 | 说明 | +|---|---|---| +| **G2 圆弧支持** | 大 | 新增 `GcodeSegmentType.arc`,painter 实现弧线绘制 | +| **多层绘制** | 大 | 背景网格/辅助线/路径分层,独立 togglable | +| **撤销/重做** | 中 | 编辑场景下的状态回退能力 | + +### 优先级 C — 远期探索 + +| 任务 | 说明 | +|---|---| +| **SVG/Bitmap → G-code 生成** | 当前包只做解析+预览,生成是反向需求 | +| **3D 预览** | 需要整体架构升级 | + +## 测试覆盖 + +``` +flutter test → 22 tests passed (parser × 11, toolpath × 3, pipeline × 3, widget × 1) +flutter analyze → 0 issues +``` diff --git a/packages/gcode_core/README.md b/packages/gcode_core/README.md new file mode 100644 index 0000000..65480fe --- /dev/null +++ b/packages/gcode_core/README.md @@ -0,0 +1,65 @@ +# gcode_core + +![example](https://github.com/lizy-coding/gcode_core/blob/master/gcode_print.gif) + +G-code parsing and visualization package extracted from `flutter_study`. + +## Scope + +- Read G-code from strings or files line by line. +- Parse G0/G1 commands with X/Y/F parameters. +- Collect parse errors with line metadata. +- Build incremental or batch toolpath segments. +- Render toolpaths with Flutter `CustomPaint`. +- Render command timelines and playback controls for Flutter frontends. + +This package does not open system file pickers or own app-level playback state. + +## Test + +```bash +flutter test +``` + +## Example + +Run the Flutter example app: + +```bash +cd example +flutter run +``` + +The example demonstrates local file selection, streaming parse snapshots, +`GcodeCanvas` drawing, `CommandTimeline`, and `PlaybackControls`. + +Run the console example: + +```bash +dart run example/gcode_core_example.dart +``` + +Minimal usage: + +```dart +import 'package:gcode_core/gcode_core.dart'; + +Future main() async { + const source = ''' +G0 X0 Y0 +G1 X10 Y0 F1200 +G1 X10 Y10 +'''; + + final pipeline = GcodeReadlinePipeline(); + + await for (final snapshot + in pipeline.load(const StringGcodeLineReader(source))) { + if (snapshot.stage == GcodeLoadStage.ready) { + print(snapshot.commands.length); + print(snapshot.segments.length); + print(snapshot.errors.length); + } + } +} +``` diff --git a/packages/gcode_core/example/.gitignore b/packages/gcode_core/example/.gitignore new file mode 100644 index 0000000..6a40388 --- /dev/null +++ b/packages/gcode_core/example/.gitignore @@ -0,0 +1,54 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Generated code +*.g.dart +*.freezed.dart +*.mocks.dart +*.pb.dart +*.pbjson.dart +*.pbenum.dart +*.grpc.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/gcode_core/example/.metadata b/packages/gcode_core/example/.metadata new file mode 100644 index 0000000..c24b9a1 --- /dev/null +++ b/packages/gcode_core/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: macos + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/gcode_core/example/README.md b/packages/gcode_core/example/README.md new file mode 100644 index 0000000..f7139cd --- /dev/null +++ b/packages/gcode_core/example/README.md @@ -0,0 +1,38 @@ +# gcode_core example + +Flutter example for the `gcode_core` package. + +It demonstrates the full local workflow: + +- Pick a local `.gcode`, `.nc`, `.tap`, or `.txt` file. +- Read the file line by line with `FileGcodeLineReader`. +- Parse supported `G0/G1` commands with `GcodeReadlinePipeline`. +- Dynamically refresh parsed snapshots while reading. +- Draw toolpath segments with the package-provided `GcodeCanvas`. +- Show `G0` jump moves as red dashed lines and `G1` cutting moves as solid paths. +- Show commands and parse errors with `CommandTimeline`. +- Preview the generated path with `PlaybackControls`. + +The main integration points are: + +```dart +final pipeline = GcodeReadlinePipeline( + options: const GcodeReadlineOptions(snapshotBatchSize: 1), +); + +await for (final snapshot in pipeline.load(FileGcodeLineReader(file.path))) { + setState(() => _snapshot = snapshot); +} + +GcodeCanvas( + segments: snapshot.segments, + progress: playbackProgress, + errorCount: snapshot.errors.length, +); +``` + +Run it from this directory: + +```bash +flutter run +``` diff --git a/packages/gcode_core/example/analysis_options.yaml b/packages/gcode_core/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/gcode_core/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/gcode_core/example/lib/main.dart b/packages/gcode_core/example/lib/main.dart new file mode 100644 index 0000000..1e12219 --- /dev/null +++ b/packages/gcode_core/example/lib/main.dart @@ -0,0 +1,485 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:gcode_core/gcode_core.dart'; + +void main() { + runApp(const GcodeCoreExampleApp()); +} + +class GcodeCoreExampleApp extends StatelessWidget { + const GcodeCoreExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'G-code Core Example', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)), + useMaterial3: true, + ), + home: const GcodeExamplePage(), + ); + } +} + +class GcodeExamplePage extends StatefulWidget { + const GcodeExamplePage({super.key}); + + @override + State createState() => _GcodeExamplePageState(); +} + +class _GcodeExamplePageState extends State { + static const _sampleSource = ''' +G0 X0 Y0 +G1 X30 Y0 F1200 +G1 X30 Y18 +G1 X12 Y18 +G0 X6 Y8 +G1 X22 Y8 +G2 X40 Y40 +'''; + + final _pipeline = GcodeReadlinePipeline( + options: const GcodeReadlineOptions(snapshotBatchSize: 1), + ); + + GcodeLoadSnapshot? _snapshot; + String _sourceName = '未选择文件'; + String _status = '请选择本地 G-code 文件,或加载内置示例。'; + bool _loading = false; + bool _isPlaying = false; + double _playbackProgress = 1; + double _speedMultiplier = 1; + Timer? _playbackTimer; + + @override + void dispose() { + _playbackTimer?.cancel(); + super.dispose(); + } + + Future _pickAndParseFile() async { + const typeGroup = XTypeGroup( + label: 'G-code', + extensions: ['gcode', 'nc', 'tap', 'txt'], + ); + + final file = await openFile(acceptedTypeGroups: [typeGroup]); + if (file == null) return; + + await _parseReader(FileGcodeLineReader(file.path), sourceName: file.name); + } + + Future _loadSample() { + return _parseReader( + const StringGcodeLineReader(_sampleSource), + sourceName: '内置示例', + ); + } + + Future _parseReader( + GcodeLineReader reader, { + required String sourceName, + }) async { + _playbackTimer?.cancel(); + setState(() { + _loading = true; + _isPlaying = false; + _playbackProgress = 1; + _sourceName = sourceName; + _snapshot = null; + _status = '正在读取 $sourceName'; + }); + + await for (final snapshot in _pipeline.load(reader)) { + if (!mounted) return; + setState(() { + _snapshot = snapshot; + _status = snapshot.message; + _playbackProgress = 1; + }); + if (snapshot.stage == GcodeLoadStage.parsing) { + await Future.delayed(const Duration(milliseconds: 16)); + } + } + + if (!mounted) return; + setState(() => _loading = false); + } + + void _play() { + if ((_snapshot?.segments.isEmpty ?? true) || _loading) return; + + _playbackTimer?.cancel(); + setState(() => _isPlaying = true); + _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { + if (!mounted) return; + final next = _playbackProgress + 0.004 * _speedMultiplier; + setState(() { + _playbackProgress = next.clamp(0, 1); + _isPlaying = _playbackProgress < 1; + }); + if (_playbackProgress >= 1) { + _playbackTimer?.cancel(); + } + }); + } + + void _pause() { + _playbackTimer?.cancel(); + setState(() => _isPlaying = false); + } + + void _resetPlayback() { + _playbackTimer?.cancel(); + setState(() { + _isPlaying = false; + _playbackProgress = 0; + }); + } + + void _seekPlayback(double value) { + setState(() => _playbackProgress = value); + } + + void _setSpeed(double value) { + setState(() => _speedMultiplier = value); + } + + int _currentCommandIndex(GcodeLoadSnapshot? snapshot) { + final commandCount = snapshot?.commands.length ?? 0; + if (commandCount == 0) return -1; + return (_playbackProgress * commandCount).ceil().clamp(1, commandCount) - 1; + } + + @override + Widget build(BuildContext context) { + final snapshot = _snapshot; + + return Scaffold( + appBar: AppBar( + title: const Text('G-code Core 绘制示例'), + actions: [ + TextButton.icon( + onPressed: _loading ? null : _loadSample, + icon: const Icon(Icons.data_object), + label: const Text('示例数据'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : _pickAndParseFile, + icon: const Icon(Icons.folder_open), + label: const Text('选择 G-code'), + ), + const SizedBox(width: 16), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _StatusBar( + sourceName: _sourceName, + status: _status, + loading: _loading, + ), + const SizedBox(height: 16), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: _CanvasPanel( + snapshot: snapshot, + parsing: _loading, + progress: _playbackProgress, + isPlaying: _isPlaying, + speedMultiplier: _speedMultiplier, + onPlay: _play, + onPause: _pause, + onReset: _resetPlayback, + onSeek: _seekPlayback, + onSpeedChange: _setSpeed, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 360, + child: _ResultPanel( + snapshot: snapshot, + currentIndex: _currentCommandIndex(snapshot), + onCommandTap: (index) { + final total = snapshot?.commands.length ?? 0; + if (total == 0) return; + _pause(); + setState(() => _playbackProgress = (index + 1) / total); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _StatusBar extends StatelessWidget { + const _StatusBar({ + required this.sourceName, + required this.status, + required this.loading, + }); + + final String sourceName; + final String status; + final bool loading; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (loading) + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.route), + const SizedBox(width: 12), + Expanded( + child: Text( + '$sourceName - $status', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +class _CanvasPanel extends StatelessWidget { + const _CanvasPanel({ + required this.snapshot, + required this.parsing, + required this.progress, + required this.isPlaying, + required this.speedMultiplier, + required this.onPlay, + required this.onPause, + required this.onReset, + required this.onSeek, + required this.onSpeedChange, + }); + + final GcodeLoadSnapshot? snapshot; + final bool parsing; + final double progress; + final bool isPlaying; + final double speedMultiplier; + final VoidCallback onPlay; + final VoidCallback onPause; + final VoidCallback onReset; + final ValueChanged onSeek; + final ValueChanged onSpeedChange; + + @override + Widget build(BuildContext context) { + final segments = snapshot?.segments ?? const []; + final errors = snapshot?.errors.length ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: GcodeCanvas( + segments: segments, + progress: parsing ? 1 : progress, + errorCount: errors, + bounds: snapshot?.bounds, + ), + ), + Positioned( + left: 12, + top: 12, + child: _CanvasLegend( + parsing: parsing, + segments: segments.length, + mainSegments: segments + .where( + (segment) => segment.type == GcodeSegmentType.linear, + ) + .length, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + PlaybackControls( + isPlaying: isPlaying, + progress: parsing ? 1 : progress, + speedMultiplier: speedMultiplier, + onPlay: onPlay, + onPause: onPause, + onReset: onReset, + onSeek: onSeek, + onSpeedChange: onSpeedChange, + ), + ], + ); + } +} + +class _CanvasLegend extends StatelessWidget { + const _CanvasLegend({ + required this.parsing, + required this.segments, + required this.mainSegments, + }); + + final bool parsing; + final int segments; + final int mainSegments; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.9), + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: DefaultTextStyle( + style: theme.textTheme.labelMedium!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(parsing ? '动态解析绘制中' : 'GcodeCanvas 绘制'), + const SizedBox(height: 4), + Text('主线段 G1: $mainSegments'), + Text('移动段 G0/G1: $segments'), + ], + ), + ), + ), + ); + } +} + +class _ResultPanel extends StatelessWidget { + const _ResultPanel({ + required this.snapshot, + required this.currentIndex, + required this.onCommandTap, + }); + + final GcodeLoadSnapshot? snapshot; + final int currentIndex; + final ValueChanged onCommandTap; + + @override + Widget build(BuildContext context) { + final current = snapshot; + + if (current == null) { + return const Center(child: Text('解析结果会显示在这里')); + } + + return ListView( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Metric(label: '行数', value: current.linesRead.toString()), + _Metric(label: '指令', value: current.commands.length.toString()), + _Metric(label: '轨迹', value: current.segments.length.toString()), + _Metric(label: '错误', value: current.errors.length.toString()), + ], + ), + const SizedBox(height: 16), + CommandTimeline( + commands: current.commands, + errors: current.errors, + currentIndex: currentIndex, + onTap: onCommandTap, + maxHeight: 360, + ), + const SizedBox(height: 16), + Text('解析错误', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (current.errors.isEmpty) + const Text('无') + else + for (final error in current.errors) + ListTile( + dense: true, + leading: const Icon(Icons.warning_amber), + title: Text('第 ${error.lineNumber} 行'), + subtitle: Text('${error.message}\n${error.rawLine}'), + ), + ], + ); + } +} + +class _Metric extends StatelessWidget { + const _Metric({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: 78, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.titleLarge), + ], + ), + ), + ), + ); + } +} diff --git a/packages/gcode_core/example/macos/.gitignore b/packages/gcode_core/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/packages/gcode_core/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/gcode_core/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/gcode_core/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/packages/gcode_core/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/gcode_core/example/macos/Flutter/Flutter-Release.xcconfig b/packages/gcode_core/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/packages/gcode_core/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..14b5f7c --- /dev/null +++ b/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) +} diff --git a/packages/gcode_core/example/macos/Podfile b/packages/gcode_core/example/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/packages/gcode_core/example/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj b/packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c59ba60 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 110DD9541D412F57D0FC25B1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + EE563B3C323D38A90A7815D1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 02484B4ED94AC349495FF031 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 0FB2C87507FF7C43AE0AB5FE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 57F4617597A1FF85FCC8B1C5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + C969A4D026E4EABF8F0A69F8 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + CF5D4B7C8248EF71D487E239 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D74F4B79EB67493B99E1AC43 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EE563B3C323D38A90A7815D1 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 110DD9541D412F57D0FC25B1 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 807FC030647A3AE78EB79582 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 807FC030647A3AE78EB79582 /* Pods */ = { + isa = PBXGroup; + children = ( + 0FB2C87507FF7C43AE0AB5FE /* Pods-Runner.debug.xcconfig */, + CF5D4B7C8248EF71D487E239 /* Pods-Runner.release.xcconfig */, + 57F4617597A1FF85FCC8B1C5 /* Pods-Runner.profile.xcconfig */, + C969A4D026E4EABF8F0A69F8 /* Pods-RunnerTests.debug.xcconfig */, + D74F4B79EB67493B99E1AC43 /* Pods-RunnerTests.release.xcconfig */, + 02484B4ED94AC349495FF031 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */, + 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + A007395C57EBD64C92800C10 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 940F9EE8C72997939F9C78C1 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 1BAF74549412E04249338CF8 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1BAF74549412E04249338CF8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 940F9EE8C72997939F9C78C1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + A007395C57EBD64C92800C10 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C969A4D026E4EABF8F0A69F8 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D74F4B79EB67493B99E1AC43 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 02484B4ED94AC349495FF031 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/gcode_core/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ac78810 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/gcode_core/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/gcode_core/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/gcode_core/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/gcode_core/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/gcode_core/example/macos/Runner/AppDelegate.swift b/packages/gcode_core/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..f67a84b --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/packages/gcode_core/example/macos/Runner/Configs/Debug.xcconfig b/packages/gcode_core/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/gcode_core/example/macos/Runner/Configs/Release.xcconfig b/packages/gcode_core/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/gcode_core/example/macos/Runner/Configs/Warnings.xcconfig b/packages/gcode_core/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/gcode_core/example/macos/Runner/DebugProfile.entitlements b/packages/gcode_core/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/gcode_core/example/macos/Runner/Info.plist b/packages/gcode_core/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift b/packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/gcode_core/example/macos/Runner/Release.entitlements b/packages/gcode_core/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/packages/gcode_core/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift b/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/gcode_core/example/pubspec.lock b/packages/gcode_core/example/pubspec.lock new file mode 100644 index 0000000..72801cb --- /dev/null +++ b/packages/gcode_core/example/pubspec.lock @@ -0,0 +1,337 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.2+6" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + gcode_core: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.1.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.38.0" diff --git a/packages/gcode_core/example/pubspec.yaml b/packages/gcode_core/example/pubspec.yaml new file mode 100644 index 0000000..4f1bc4a --- /dev/null +++ b/packages/gcode_core/example/pubspec.yaml @@ -0,0 +1,93 @@ +name: example +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + gcode_core: + path: ../ + file_selector: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/packages/gcode_core/example/test/widget_test.dart b/packages/gcode_core/example/test/widget_test.dart new file mode 100644 index 0000000..7c72bb2 --- /dev/null +++ b/packages/gcode_core/example/test/widget_test.dart @@ -0,0 +1,19 @@ +import 'package:gcode_core/gcode_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('GcodeReadlinePipeline smoke test', (WidgetTester tester) async { + final pipeline = GcodeReadlinePipeline( + options: const GcodeReadlineOptions(snapshotBatchSize: 2), + ); + + final snapshots = await pipeline + .load(const StringGcodeLineReader('G0 X0 Y0\nG1 X10 Y0\n')) + .toList(); + + expect(snapshots.isNotEmpty, true); + final last = snapshots.last; + expect(last.stage, GcodeLoadStage.ready); + expect(last.commands.length, 2); + }); +} diff --git a/packages/gcode_core/gcode_print.gif b/packages/gcode_core/gcode_print.gif new file mode 100644 index 0000000..29c3f7c Binary files /dev/null and b/packages/gcode_core/gcode_print.gif differ diff --git a/packages/gcode_core/lib/gcode_core.dart b/packages/gcode_core/lib/gcode_core.dart new file mode 100644 index 0000000..c100cd6 --- /dev/null +++ b/packages/gcode_core/lib/gcode_core.dart @@ -0,0 +1,21 @@ +library gcode_core; + +export 'src/application/gcode_readline_pipeline.dart'; +export 'src/core/gcode_bounds.dart'; +export 'src/core/gcode_style.dart'; +export 'src/data/readers/file_gcode_line_reader.dart'; +export 'src/data/readers/gcode_line_reader.dart'; +export 'src/data/readers/string_gcode_line_reader.dart'; +export 'src/domain/gcode_line_record.dart'; +export 'src/domain/gcode_load_snapshot.dart'; +export 'src/domain/gcode_load_stage.dart'; +export 'src/domain/parsed_gcode_line.dart'; +export 'src/models/gcode_command.dart'; +export 'src/models/machine_position.dart'; +export 'src/models/toolpath_segment.dart'; +export 'src/parser/gcode_parse_result.dart'; +export 'src/parser/gcode_parser.dart'; +export 'src/services/toolpath_builder.dart'; +export 'src/widgets/command_timeline.dart'; +export 'src/widgets/gcode_canvas.dart'; +export 'src/widgets/playback_controls.dart'; diff --git a/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart b/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart new file mode 100644 index 0000000..378415c --- /dev/null +++ b/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import '../data/readers/gcode_line_reader.dart'; +import '../domain/gcode_line_record.dart'; +import '../domain/gcode_load_snapshot.dart'; +import '../domain/gcode_load_stage.dart'; +import '../domain/parsed_gcode_line.dart'; +import '../models/gcode_command.dart'; +import '../models/toolpath_segment.dart'; +import '../parser/gcode_parse_result.dart'; +import '../parser/gcode_parser.dart'; +import '../services/toolpath_builder.dart'; + +class GcodeReadlineOptions { + const GcodeReadlineOptions({ + this.snapshotBatchSize = 200, + }); + + final int snapshotBatchSize; +} + +class GcodeReadlinePipeline { + GcodeReadlinePipeline({ + GcodeParser? parser, + IncrementalToolpathBuilder? toolpathBuilder, + this.options = const GcodeReadlineOptions(), + }) : _parser = parser ?? GcodeParser(), + _toolpathBuilder = toolpathBuilder ?? IncrementalToolpathBuilder(); + + final GcodeParser _parser; + final IncrementalToolpathBuilder _toolpathBuilder; + final GcodeReadlineOptions options; + + Stream load(GcodeLineReader reader) async* { + final commands = []; + final errors = []; + final segments = []; + var linesRead = 0; + var changedSinceSnapshot = 0; + + _toolpathBuilder.reset(); + + yield const GcodeLoadSnapshot( + stage: GcodeLoadStage.reading, + commands: [], + errors: [], + segments: [], + linesRead: 0, + message: '开始逐行读取', + ); + + try { + await for (final record in reader.readLines()) { + linesRead = record.lineNumber; + final parsed = _parser.parseRecord(record); + + switch (parsed.kind) { + case ParsedGcodeLineKind.command: + final command = parsed.command!; + commands.add(command); + final segment = _toolpathBuilder.accept(command); + if (segment != null) { + segments.add(segment); + } + case ParsedGcodeLineKind.error: + errors.add(parsed.error!); + case ParsedGcodeLineKind.skipped: + break; + } + + changedSinceSnapshot++; + if (changedSinceSnapshot >= options.snapshotBatchSize) { + changedSinceSnapshot = 0; + yield _snapshot( + stage: GcodeLoadStage.parsing, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '已读取 $linesRead 行', + ); + } + } + + yield _snapshot( + stage: GcodeLoadStage.ready, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '逐行读取完成: $linesRead 行, ' + '${commands.length} 条指令, ${errors.length} 个错误, ' + '${segments.length} 条轨迹段', + ); + } catch (error) { + yield _snapshot( + stage: GcodeLoadStage.failed, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '读取失败: $error', + ); + } + } + + Stream loadFileInBackground(String filePath) { + final receivePort = ReceivePort(); + + Isolate.spawn( + _isolateLoadFile, + (filePath, options, receivePort.sendPort), + ); + + return receivePort + .takeWhile((msg) => msg is! _IsolateDone) + .cast(); + } + + Stream loadStringInBackground(String source) { + final receivePort = ReceivePort(); + + Isolate.spawn( + _isolateLoadString, + (source, options, receivePort.sendPort), + ); + + return receivePort + .takeWhile((msg) => msg is! _IsolateDone) + .cast(); + } + + GcodeLoadSnapshot _snapshot({ + required GcodeLoadStage stage, + required List commands, + required List errors, + required List segments, + required int linesRead, + required String message, + }) { + final b = _toolpathBuilder.bounds; + return GcodeLoadSnapshot( + stage: stage, + commands: List.unmodifiable(commands), + errors: List.unmodifiable(errors), + segments: List.unmodifiable(segments), + linesRead: linesRead, + message: message, + bounds: + b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 ? b : null, + ); + } + + static void _isolateLoadFile( + (String, GcodeReadlineOptions, SendPort) args, + ) { + () async { + final (filePath, options, sendPort) = args; + final parser = GcodeParser(); + final builder = IncrementalToolpathBuilder(); + + final commands = []; + final errors = []; + final segments = []; + var linesRead = 0; + var changedSinceSnapshot = 0; + + try { + sendPort.send( + const GcodeLoadSnapshot( + stage: GcodeLoadStage.reading, + commands: [], + errors: [], + segments: [], + linesRead: 0, + message: '开始逐行读取', + ), + ); + + final file = File(filePath); + final stream = file + .openRead() + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in stream) { + linesRead++; + final record = GcodeLineRecord( + lineNumber: linesRead, + rawLine: line, + byteOffset: 0, + ); + + final parsed = parser.parseRecord(record); + + switch (parsed.kind) { + case ParsedGcodeLineKind.command: + final command = parsed.command!; + commands.add(command); + final segment = builder.accept(command); + if (segment != null) { + segments.add(segment); + } + case ParsedGcodeLineKind.error: + errors.add(parsed.error!); + case ParsedGcodeLineKind.skipped: + break; + } + + changedSinceSnapshot++; + if (changedSinceSnapshot >= options.snapshotBatchSize) { + changedSinceSnapshot = 0; + final b = builder.bounds; + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.parsing, + commands: List.unmodifiable(commands), + errors: List.unmodifiable(errors), + segments: List.unmodifiable(segments), + linesRead: linesRead, + message: '已读取 $linesRead 行', + bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 + ? b + : null, + ), + ); + } + } + + final b = builder.bounds; + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.ready, + commands: List.unmodifiable(commands), + errors: List.unmodifiable(errors), + segments: List.unmodifiable(segments), + linesRead: linesRead, + message: '逐行读取完成: $linesRead 行, ' + '${commands.length} 条指令, ${errors.length} 个错误, ' + '${segments.length} 条轨迹段', + bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 + ? b + : null, + ), + ); + } catch (error) { + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.failed, + commands: List.unmodifiable(commands), + errors: List.unmodifiable(errors), + segments: List.unmodifiable(segments), + linesRead: linesRead, + message: '读取失败: $error', + ), + ); + } + + sendPort.send(const _IsolateDone()); + }(); + } + + static void _isolateLoadString( + (String, GcodeReadlineOptions, SendPort) args, + ) { + final (source, options, sendPort) = args; + final parser = GcodeParser(); + final builder = IncrementalToolpathBuilder(); + + final commands = []; + final errors = []; + final segments = []; + var linesRead = 0; + var changedSinceSnapshot = 0; + + try { + sendPort.send( + const GcodeLoadSnapshot( + stage: GcodeLoadStage.reading, + commands: [], + errors: [], + segments: [], + linesRead: 0, + message: '开始逐行读取', + ), + ); + + for (final line in const LineSplitter().convert(source)) { + linesRead++; + final record = GcodeLineRecord( + lineNumber: linesRead, + rawLine: line, + byteOffset: 0, + ); + + final parsed = parser.parseRecord(record); + + switch (parsed.kind) { + case ParsedGcodeLineKind.command: + final command = parsed.command!; + commands.add(command); + final segment = builder.accept(command); + if (segment != null) { + segments.add(segment); + } + case ParsedGcodeLineKind.error: + errors.add(parsed.error!); + case ParsedGcodeLineKind.skipped: + break; + } + + changedSinceSnapshot++; + if (changedSinceSnapshot >= options.snapshotBatchSize) { + changedSinceSnapshot = 0; + final b = builder.bounds; + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.parsing, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '已读取 $linesRead 行', + bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 + ? b + : null, + ), + ); + } + } + + final b = builder.bounds; + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.ready, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '逐行读取完成: $linesRead 行, ' + '${commands.length} 条指令, ${errors.length} 个错误, ' + '${segments.length} 条轨迹段', + bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 + ? b + : null, + ), + ); + } catch (error) { + sendPort.send( + GcodeLoadSnapshot( + stage: GcodeLoadStage.failed, + commands: commands, + errors: errors, + segments: segments, + linesRead: linesRead, + message: '读取失败: $error', + ), + ); + } + + sendPort.send(const _IsolateDone()); + } +} + +class _IsolateDone { + const _IsolateDone(); +} diff --git a/packages/gcode_core/lib/src/core/gcode_bounds.dart b/packages/gcode_core/lib/src/core/gcode_bounds.dart new file mode 100644 index 0000000..22c26a2 --- /dev/null +++ b/packages/gcode_core/lib/src/core/gcode_bounds.dart @@ -0,0 +1,24 @@ +class GcodeBounds { + const GcodeBounds({ + required this.minX, + required this.maxX, + required this.minY, + required this.maxY, + }); + + static const zero = GcodeBounds(minX: 0, maxX: 0, minY: 0, maxY: 0); + + final double minX; + final double maxX; + final double minY; + final double maxY; + + GcodeBounds expand(double x, double y) { + return GcodeBounds( + minX: x < minX ? x : minX, + maxX: x > maxX ? x : maxX, + minY: y < minY ? y : minY, + maxY: y > maxY ? y : maxY, + ); + } +} diff --git a/packages/gcode_core/lib/src/core/gcode_style.dart b/packages/gcode_core/lib/src/core/gcode_style.dart new file mode 100644 index 0000000..4cc5ec2 --- /dev/null +++ b/packages/gcode_core/lib/src/core/gcode_style.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +class GcodeStyle { + const GcodeStyle({ + required this.rapidMovePaint, + required this.rapidMoveBgPaint, + required this.linearMovePaint, + required this.linearMoveBgPaint, + required this.toolHeadPaint, + required this.toolHeadGlowPaint, + required this.originPaint, + required this.originDotPaint, + required this.gridPaint, + }); + + final Paint rapidMovePaint; + final Paint rapidMoveBgPaint; + final Paint linearMovePaint; + final Paint linearMoveBgPaint; + final Paint toolHeadPaint; + final Paint toolHeadGlowPaint; + final Paint originPaint; + final Paint originDotPaint; + final Paint gridPaint; + + factory GcodeStyle.light({ + Color rapidColor = Colors.red, + Color linearColor = Colors.green, + Color toolHeadColor = Colors.red, + Color originColor = const Color(0x99FF9800), + Color gridColor = const Color(0x26000000), + }) { + return GcodeStyle( + rapidMovePaint: Paint() + ..color = rapidColor + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round, + rapidMoveBgPaint: Paint() + ..color = rapidColor.withValues(alpha: 0.25) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + linearMovePaint: Paint() + ..color = linearColor + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round, + linearMoveBgPaint: Paint() + ..color = linearColor.withValues(alpha: 0.15) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + toolHeadPaint: Paint() + ..color = toolHeadColor + ..style = PaintingStyle.fill, + toolHeadGlowPaint: Paint() + ..color = toolHeadColor.withValues(alpha: 0.3) + ..style = PaintingStyle.fill, + originPaint: Paint() + ..color = originColor + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke, + originDotPaint: Paint() + ..color = originColor + ..style = PaintingStyle.fill, + gridPaint: Paint() + ..color = gridColor + ..strokeWidth = 0.5, + ); + } +} diff --git a/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart new file mode 100644 index 0000000..b90532b --- /dev/null +++ b/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../domain/gcode_line_record.dart'; +import 'gcode_line_reader.dart'; + +class FileGcodeLineReader implements GcodeLineReader { + const FileGcodeLineReader(this.path); + + final String path; + + static String normalizePath(String value) { + var normalized = value.trim(); + if (normalized.length >= 2) { + final first = normalized[0]; + final last = normalized[normalized.length - 1]; + if ((first == '"' && last == '"') || (first == "'" && last == "'")) { + normalized = normalized.substring(1, normalized.length - 1).trim(); + } + } + + if (normalized.startsWith('file://')) { + normalized = + Uri.parse(normalized).toFilePath(windows: Platform.isWindows); + } + + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + if (home.isNotEmpty && normalized == '~') { + normalized = home; + } else if (home.isNotEmpty && normalized.startsWith('~/')) { + normalized = '$home/${normalized.substring(2)}'; + } + + if (!Platform.isWindows) { + normalized = normalized.replaceAllMapped( + RegExp(r'\\([ ()\[\]&;])'), + (match) => match.group(1)!, + ); + } + + return normalized; + } + + @override + Stream readLines() async* { + final normalizedPath = normalizePath(path); + final type = await FileSystemEntity.type(normalizedPath); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException('文件不存在', normalizedPath); + } + if (type == FileSystemEntityType.directory) { + throw FileSystemException('路径是目录,不是 G-code 文件', normalizedPath); + } + + final file = File(normalizedPath); + var lineNumber = 0; + var byteOffset = 0; + + await for (final line in file + .openRead() + .transform(utf8.decoder) + .transform(const LineSplitter())) { + lineNumber++; + yield GcodeLineRecord( + lineNumber: lineNumber, + rawLine: line, + byteOffset: byteOffset, + ); + byteOffset += utf8.encode(line).length + 1; + } + } +} diff --git a/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart new file mode 100644 index 0000000..4691395 --- /dev/null +++ b/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart @@ -0,0 +1,5 @@ +import '../../domain/gcode_line_record.dart'; + +abstract interface class GcodeLineReader { + Stream readLines(); +} diff --git a/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart new file mode 100644 index 0000000..a122ef8 --- /dev/null +++ b/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart @@ -0,0 +1,39 @@ +import '../../domain/gcode_line_record.dart'; +import 'gcode_line_reader.dart'; + +class StringGcodeLineReader implements GcodeLineReader { + const StringGcodeLineReader(this.source); + + final String source; + + @override + Stream readLines() async* { + var lineNumber = 0; + var byteOffset = 0; + var start = 0; + + for (var i = 0; i < source.length; i++) { + final codeUnit = source.codeUnitAt(i); + if (codeUnit != 10) continue; + + lineNumber++; + final end = i > start && source.codeUnitAt(i - 1) == 13 ? i - 1 : i; + yield GcodeLineRecord( + lineNumber: lineNumber, + rawLine: source.substring(start, end), + byteOffset: byteOffset, + ); + byteOffset = i + 1; + start = i + 1; + } + + if (start < source.length || source.isEmpty) { + lineNumber++; + yield GcodeLineRecord( + lineNumber: lineNumber, + rawLine: source.substring(start), + byteOffset: byteOffset, + ); + } + } +} diff --git a/packages/gcode_core/lib/src/domain/gcode_line_record.dart b/packages/gcode_core/lib/src/domain/gcode_line_record.dart new file mode 100644 index 0000000..be8fa24 --- /dev/null +++ b/packages/gcode_core/lib/src/domain/gcode_line_record.dart @@ -0,0 +1,11 @@ +class GcodeLineRecord { + const GcodeLineRecord({ + required this.lineNumber, + required this.rawLine, + required this.byteOffset, + }); + + final int lineNumber; + final String rawLine; + final int byteOffset; +} diff --git a/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart b/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart new file mode 100644 index 0000000..2e5bc95 --- /dev/null +++ b/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart @@ -0,0 +1,41 @@ +import '../core/gcode_bounds.dart'; +import '../models/gcode_command.dart'; +import '../models/toolpath_segment.dart'; +import '../parser/gcode_parse_result.dart'; +import 'gcode_load_stage.dart'; + +class GcodeLoadSnapshot { + const GcodeLoadSnapshot({ + required this.stage, + required this.commands, + required this.errors, + required this.segments, + required this.linesRead, + this.message = '', + this.diagnosticMessage = '', + this.bounds, + }); + + factory GcodeLoadSnapshot.empty() { + return const GcodeLoadSnapshot( + stage: GcodeLoadStage.idle, + commands: [], + errors: [], + segments: [], + linesRead: 0, + ); + } + + final GcodeLoadStage stage; + final List commands; + final List errors; + final List segments; + final int linesRead; + final String message; + final String diagnosticMessage; + final GcodeBounds? bounds; + + GcodeParseResult toParseResult() { + return GcodeParseResult(commands: commands, errors: errors); + } +} diff --git a/packages/gcode_core/lib/src/domain/gcode_load_stage.dart b/packages/gcode_core/lib/src/domain/gcode_load_stage.dart new file mode 100644 index 0000000..dffe2da --- /dev/null +++ b/packages/gcode_core/lib/src/domain/gcode_load_stage.dart @@ -0,0 +1,7 @@ +enum GcodeLoadStage { + idle, + reading, + parsing, + ready, + failed, +} diff --git a/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart b/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart new file mode 100644 index 0000000..712a52b --- /dev/null +++ b/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart @@ -0,0 +1,51 @@ +import '../models/gcode_command.dart'; +import '../parser/gcode_parse_result.dart'; +import 'gcode_line_record.dart'; + +enum ParsedGcodeLineKind { command, error, skipped } + +class ParsedGcodeLine { + const ParsedGcodeLine._({ + required this.kind, + required this.record, + this.command, + this.error, + }); + + factory ParsedGcodeLine.command( + GcodeLineRecord record, + GcodeCommand command, + ) { + return ParsedGcodeLine._( + kind: ParsedGcodeLineKind.command, + record: record, + command: command, + ); + } + + factory ParsedGcodeLine.error( + GcodeLineRecord record, + GcodeParseError error, + ) { + return ParsedGcodeLine._( + kind: ParsedGcodeLineKind.error, + record: record, + error: error, + ); + } + + factory ParsedGcodeLine.skipped(GcodeLineRecord record) { + return ParsedGcodeLine._( + kind: ParsedGcodeLineKind.skipped, + record: record, + ); + } + + final ParsedGcodeLineKind kind; + final GcodeLineRecord record; + final GcodeCommand? command; + final GcodeParseError? error; + + bool get hasCommand => kind == ParsedGcodeLineKind.command; + bool get hasError => kind == ParsedGcodeLineKind.error; +} diff --git a/packages/gcode_core/lib/src/models/gcode_command.dart b/packages/gcode_core/lib/src/models/gcode_command.dart new file mode 100644 index 0000000..46fe468 --- /dev/null +++ b/packages/gcode_core/lib/src/models/gcode_command.dart @@ -0,0 +1,31 @@ +import 'machine_position.dart'; + +enum GcodeSegmentType { rapid, linear } + +class GcodeCommand { + const GcodeCommand({ + required this.lineNumber, + required this.rawLine, + required this.code, + required this.params, + this.comment = '', + }); + + final int lineNumber; + final String rawLine; + final String code; + final Map params; + final String comment; + + double? get x => params['X']; + double? get y => params['Y']; + double? get feedRate => params['F']; + + MachinePosition toPosition(MachinePosition current) { + return MachinePosition( + x: x ?? current.x, + y: y ?? current.y, + feedRate: feedRate ?? current.feedRate, + ); + } +} diff --git a/packages/gcode_core/lib/src/models/machine_position.dart b/packages/gcode_core/lib/src/models/machine_position.dart new file mode 100644 index 0000000..057d958 --- /dev/null +++ b/packages/gcode_core/lib/src/models/machine_position.dart @@ -0,0 +1,24 @@ +class MachinePosition { + const MachinePosition({ + this.x = 0, + this.y = 0, + this.feedRate = 0, + }); + + final double x; + final double y; + final double feedRate; + + MachinePosition copyWith({double? x, double? y, double? feedRate}) { + return MachinePosition( + x: x ?? this.x, + y: y ?? this.y, + feedRate: feedRate ?? this.feedRate, + ); + } + + @override + String toString() { + return 'MachinePosition(x: $x, y: $y, F: $feedRate)'; + } +} diff --git a/packages/gcode_core/lib/src/models/toolpath_segment.dart b/packages/gcode_core/lib/src/models/toolpath_segment.dart new file mode 100644 index 0000000..00a2722 --- /dev/null +++ b/packages/gcode_core/lib/src/models/toolpath_segment.dart @@ -0,0 +1,16 @@ +import 'gcode_command.dart'; +import 'machine_position.dart'; + +class ToolpathSegment { + const ToolpathSegment({ + required this.start, + required this.end, + required this.command, + required this.type, + }); + + final MachinePosition start; + final MachinePosition end; + final GcodeCommand command; + final GcodeSegmentType type; +} diff --git a/packages/gcode_core/lib/src/parser/gcode_parse_result.dart b/packages/gcode_core/lib/src/parser/gcode_parse_result.dart new file mode 100644 index 0000000..87b3966 --- /dev/null +++ b/packages/gcode_core/lib/src/parser/gcode_parse_result.dart @@ -0,0 +1,28 @@ +import '../models/gcode_command.dart'; + +class GcodeParseError { + const GcodeParseError({ + required this.lineNumber, + required this.rawLine, + required this.message, + }); + + final int lineNumber; + final String rawLine; + final String message; + + @override + String toString() => 'Line $lineNumber: $message (raw: "$rawLine")'; +} + +class GcodeParseResult { + const GcodeParseResult({ + required this.commands, + required this.errors, + }); + + final List commands; + final List errors; + + bool get hasErrors => errors.isNotEmpty; +} diff --git a/packages/gcode_core/lib/src/parser/gcode_parser.dart b/packages/gcode_core/lib/src/parser/gcode_parser.dart new file mode 100644 index 0000000..52e0ada --- /dev/null +++ b/packages/gcode_core/lib/src/parser/gcode_parser.dart @@ -0,0 +1,205 @@ +import '../models/gcode_command.dart'; +import '../domain/gcode_line_record.dart'; +import '../domain/parsed_gcode_line.dart'; +import 'gcode_parse_result.dart'; + +class GcodeParser { + static const _supportedCodes = {'G0', 'G00', 'G1', 'G01', 'G90', 'G91'}; + static final _paramPattern = RegExp(r'^([A-Za-z])(-?(?:\d+\.?\d*|\.\d+))$'); + + GcodeParseResult parse(String source) { + final commands = []; + final errors = []; + final lines = source.split('\n'); + + for (var i = 0; i < lines.length; i++) { + final lineNumber = i + 1; + final rawLine = lines[i].trim(); + + if (rawLine.isEmpty) continue; + + final result = parseLine(rawLine, lineNumber); + + result.when( + command: (cmd) => commands.add(cmd), + error: (err) => errors.add(err), + skipped: () {}, + ); + } + + return GcodeParseResult(commands: commands, errors: errors); + } + + ParsedGcodeLine parseRecord(GcodeLineRecord record) { + final result = parseLine(record.rawLine.trim(), record.lineNumber); + return result.when( + command: (cmd) => ParsedGcodeLine.command(record, cmd), + error: (err) => ParsedGcodeLine.error(record, err), + skipped: () => ParsedGcodeLine.skipped(record), + ); + } + + LineParseResult parseLine(String rawLine, int lineNumber) { + var line = rawLine; + + line = _removeParenthesesComments(line); + + final comment = _extractSemicolonComment(line); + line = comment != null + ? line.substring(0, line.indexOf(';')).trim() + : line.trim(); + + if (line.isEmpty) { + return LineParseResult.skipped(); + } + + final tokens = _tokenize(line); + if (tokens.isEmpty) { + return LineParseResult.skipped(); + } + + final commandCode = tokens[0].toUpperCase(); + + final normalized = _normalizeCode(commandCode); + if (!_supportedCodes.contains(normalized)) { + return LineParseResult.error( + GcodeParseError( + lineNumber: lineNumber, + rawLine: rawLine, + message: 'Unsupported code: $commandCode', + ), + ); + } + + final params = {}; + for (var i = 1; i < tokens.length; i++) { + final token = tokens[i]; + final match = _paramPattern.firstMatch(token); + if (match == null) { + return LineParseResult.error( + GcodeParseError( + lineNumber: lineNumber, + rawLine: rawLine, + message: 'Malformed parameter: $token', + ), + ); + } + final key = match.group(1)!.toUpperCase(); + final valueStr = match.group(2); + if (valueStr == null) { + return LineParseResult.error( + GcodeParseError( + lineNumber: lineNumber, + rawLine: rawLine, + message: 'Missing numeric value in: $token', + ), + ); + } + final value = double.tryParse(valueStr); + if (value == null) { + return LineParseResult.error( + GcodeParseError( + lineNumber: lineNumber, + rawLine: rawLine, + message: 'Invalid numeric value: $token', + ), + ); + } + params[key] = value; + } + + return LineParseResult.command( + GcodeCommand( + lineNumber: lineNumber, + rawLine: rawLine, + code: normalized, + params: params, + comment: comment ?? '', + ), + ); + } + + String _removeParenthesesComments(String line) { + final result = StringBuffer(); + var inComment = false; + for (var i = 0; i < line.length; i++) { + final ch = line[i]; + if (ch == '(') { + inComment = true; + } else if (ch == ')') { + inComment = false; + } else if (!inComment) { + result.write(ch); + } + } + return result.toString(); + } + + String? _extractSemicolonComment(String line) { + final index = line.indexOf(';'); + if (index == -1) return null; + return line.substring(index + 1).trim(); + } + + List _tokenize(String line) { + final tokens = []; + final buffer = StringBuffer(); + for (var i = 0; i < line.length; i++) { + final ch = line[i]; + if (ch == ' ' || ch == '\t') { + if (buffer.isNotEmpty) { + tokens.add(buffer.toString()); + buffer.clear(); + } + } else { + buffer.write(ch); + } + } + if (buffer.isNotEmpty) { + tokens.add(buffer.toString()); + } + return tokens; + } + + String _normalizeCode(String code) { + return switch (code) { + 'G0' || 'G00' => 'G0', + 'G1' || 'G01' => 'G1', + _ => code, + }; + } +} + +sealed class LineParseResult { + const LineParseResult(); + + factory LineParseResult.command(GcodeCommand cmd) => _CommandResult(cmd); + factory LineParseResult.error(GcodeParseError err) => _ErrorResult(err); + factory LineParseResult.skipped() => const _SkippedResult(); + + T when({ + required T Function(GcodeCommand) command, + required T Function(GcodeParseError) error, + required T Function() skipped, + }) { + return switch (this) { + _CommandResult(:final cmd) => command(cmd), + _ErrorResult(:final err) => error(err), + _SkippedResult() => skipped(), + }; + } +} + +class _CommandResult extends LineParseResult { + const _CommandResult(this.cmd); + final GcodeCommand cmd; +} + +class _ErrorResult extends LineParseResult { + const _ErrorResult(this.err); + final GcodeParseError err; +} + +class _SkippedResult extends LineParseResult { + const _SkippedResult(); +} diff --git a/packages/gcode_core/lib/src/services/toolpath_builder.dart b/packages/gcode_core/lib/src/services/toolpath_builder.dart new file mode 100644 index 0000000..ad3fc96 --- /dev/null +++ b/packages/gcode_core/lib/src/services/toolpath_builder.dart @@ -0,0 +1,107 @@ +import '../core/gcode_bounds.dart'; +import '../models/gcode_command.dart'; +import '../models/machine_position.dart'; +import '../models/toolpath_segment.dart'; + +enum CoordinateMode { absolute, relative } + +MachinePosition _applyCommand( + GcodeCommand cmd, MachinePosition current, CoordinateMode mode) { + if (mode == CoordinateMode.absolute) { + return cmd.toPosition(current); + } + return MachinePosition( + x: cmd.x != null ? current.x + cmd.x! : current.x, + y: cmd.y != null ? current.y + cmd.y! : current.y, + feedRate: cmd.feedRate ?? current.feedRate, + ); +} + +class ToolpathBuilder { + static List build(List commands) { + final segments = []; + var current = const MachinePosition(); + var mode = CoordinateMode.absolute; + + for (final cmd in commands) { + if (cmd.code == 'G90') { + mode = CoordinateMode.absolute; + continue; + } + if (cmd.code == 'G91') { + mode = CoordinateMode.relative; + continue; + } + + final next = _applyCommand(cmd, current, mode); + + if (next.x != current.x || next.y != current.y) { + final type = + cmd.code == 'G0' ? GcodeSegmentType.rapid : GcodeSegmentType.linear; + + segments.add( + ToolpathSegment( + start: current, + end: next, + command: cmd, + type: type, + ), + ); + } + + current = next; + } + + return segments; + } +} + +class IncrementalToolpathBuilder { + MachinePosition _current = const MachinePosition(); + GcodeBounds _bounds = GcodeBounds.zero; + CoordinateMode _mode = CoordinateMode.absolute; + + MachinePosition get current => _current; + + GcodeBounds get bounds => _bounds; + + CoordinateMode get coordinateMode => _mode; + + ToolpathSegment? accept(GcodeCommand command) { + if (command.code == 'G90') { + _mode = CoordinateMode.absolute; + return null; + } + if (command.code == 'G91') { + _mode = CoordinateMode.relative; + return null; + } + + final next = _applyCommand(command, _current, _mode); + + if (next.x == _current.x && next.y == _current.y) { + _current = next; + return null; + } + + _bounds = _bounds.expand(_current.x, _current.y).expand(next.x, next.y); + + final segment = ToolpathSegment( + start: _current, + end: next, + command: command, + type: command.code == 'G0' + ? GcodeSegmentType.rapid + : GcodeSegmentType.linear, + ); + + _current = next; + return segment; + } + + void reset() { + _current = const MachinePosition(); + _bounds = GcodeBounds.zero; + _mode = CoordinateMode.absolute; + } +} diff --git a/packages/gcode_core/lib/src/widgets/command_timeline.dart b/packages/gcode_core/lib/src/widgets/command_timeline.dart new file mode 100644 index 0000000..9b8f78b --- /dev/null +++ b/packages/gcode_core/lib/src/widgets/command_timeline.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; + +import '../models/gcode_command.dart'; +import '../parser/gcode_parse_result.dart'; + +class CommandTimeline extends StatelessWidget { + const CommandTimeline({ + super.key, + required this.commands, + required this.errors, + this.currentIndex = -1, + this.onTap, + this.maxHeight, + }); + + final List commands; + final List errors; + final int currentIndex; + final ValueChanged? onTap; + final double? maxHeight; + + @override + Widget build(BuildContext context) { + final items = _buildTimelineItems(); + + return Container( + constraints: + maxHeight != null ? BoxConstraints(maxHeight: maxHeight!) : null, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Text( + '指令列表 (${commands.length})', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (errors.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${errors.length} 错误', + style: const TextStyle( + fontSize: 11, + color: Colors.red, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final cmd = item.command; + final error = item.error; + final commandIndex = cmd == null ? -1 : commands.indexOf(cmd); + final isCurrent = + commandIndex >= 0 && commandIndex == currentIndex; + final hasError = error != null; + final code = cmd?.code; + + return InkWell( + onTap: onTap != null && commandIndex >= 0 + ? () => onTap!(commandIndex) + : null, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isCurrent + ? Theme.of(context).colorScheme.primaryContainer + : hasError + ? Colors.red.withValues(alpha: 0.05) + : null, + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + '${item.lineNumber}', + style: TextStyle( + fontSize: 11, + color: hasError + ? Colors.red.shade500 + : Colors.grey.shade500, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: hasError + ? Colors.red.withValues(alpha: 0.15) + : code == 'G0' + ? Colors.blue.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + hasError ? 'ERR' : code ?? '', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: hasError + ? Colors.red + : code == 'G0' + ? Colors.blue + : Colors.green, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.rawLine, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: hasError + ? Colors.red.shade700 + : isCurrent + ? null + : Colors.grey.shade700, + ), + ), + if (hasError) + Text( + error.message, + style: TextStyle( + fontSize: 11, + color: Colors.red.shade600, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + List<_TimelineItem> _buildTimelineItems() { + final items = <_TimelineItem>[ + for (final command in commands) _TimelineItem.command(command), + for (final error in errors) _TimelineItem.error(error), + ]; + items.sort((a, b) => a.lineNumber.compareTo(b.lineNumber)); + return items; + } +} + +class _TimelineItem { + const _TimelineItem._({ + required this.lineNumber, + required this.rawLine, + this.command, + this.error, + }); + + factory _TimelineItem.command(GcodeCommand command) => _TimelineItem._( + lineNumber: command.lineNumber, + rawLine: command.rawLine, + command: command, + ); + + factory _TimelineItem.error(GcodeParseError error) => _TimelineItem._( + lineNumber: error.lineNumber, + rawLine: error.rawLine, + error: error, + ); + + final int lineNumber; + final String rawLine; + final GcodeCommand? command; + final GcodeParseError? error; +} diff --git a/packages/gcode_core/lib/src/widgets/gcode_canvas.dart b/packages/gcode_core/lib/src/widgets/gcode_canvas.dart new file mode 100644 index 0000000..e5451e5 --- /dev/null +++ b/packages/gcode_core/lib/src/widgets/gcode_canvas.dart @@ -0,0 +1,502 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../core/gcode_bounds.dart'; +import '../core/gcode_style.dart'; +import '../models/gcode_command.dart'; +import '../models/toolpath_segment.dart'; + +class GcodeCanvas extends StatelessWidget { + const GcodeCanvas({ + super.key, + required this.segments, + required this.progress, + this.errorCount = 0, + this.commandCount = 0, + this.bounds, + this.style, + this.showLegend = true, + }); + + final List segments; + final double progress; + final int errorCount; + final int commandCount; + final GcodeBounds? bounds; + final GcodeStyle? style; + final bool showLegend; + + @override + Widget build(BuildContext context) { + final effectiveStyle = style ?? GcodeStyle.light(); + + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: LayoutBuilder( + builder: (context, constraints) { + Widget content; + + if (segments.isEmpty && commandCount == 0) { + content = _buildEmptyState(); + } else if (segments.isEmpty && commandCount > 0) { + content = _buildNoMovementState(commandCount); + } else if (segments.isNotEmpty && errorCount > 0) { + content = _buildPartialErrorState(constraints, effectiveStyle); + } else { + content = Stack( + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _ToolpathPainter( + segments: segments, + progress: progress, + bounds: bounds, + style: effectiveStyle, + ), + ), + if (showLegend) + Positioned( + left: 8, + bottom: 8, + child: _CanvasLegend(style: effectiveStyle), + ), + ], + ); + } + + return content; + }, + ), + ); + } + + Widget _buildEmptyState() { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.draw_outlined, size: 48, color: Colors.grey), + SizedBox(height: 12), + Text( + '输入并解析 G-code 后显示轨迹', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ], + ), + ); + } + + Widget _buildNoMovementState(int cmds) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 48, color: Colors.orange.shade300), + const SizedBox(height: 12), + Text( + '已解析 $cmds 条指令,但未产生运动轨迹', + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + '提示:G1 F1200 仅设置进给率,需配合 X/Y 坐标', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ); + } + + Widget _buildPartialErrorState(BoxConstraints constraints, GcodeStyle style) { + return Stack( + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _ToolpathPainter( + segments: segments, + progress: progress, + bounds: bounds, + style: style, + ), + ), + if (showLegend) + Positioned( + left: 8, + bottom: 8, + child: _CanvasLegend(style: style), + ), + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$errorCount 个解析错误', + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + } +} + +class _CanvasLegend extends StatelessWidget { + const _CanvasLegend({required this.style}); + + final GcodeStyle style; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _legendRow(style.rapidMovePaint, 'G0 快速移动', isDashed: true), + const SizedBox(height: 4), + _legendRow(style.linearMovePaint, 'G1 线性移动'), + const SizedBox(height: 4), + _legendRow(style.toolHeadPaint, '当前刀头', isCircle: true), + const SizedBox(height: 4), + _legendRow( + Paint() + ..color = const Color(0x99FF9800) + ..strokeWidth = 1.5, + '原点', + isCross: true), + ], + ), + ); + } + + Widget _legendRow(Paint paint, String label, + {bool isDashed = false, bool isCircle = false, bool isCross = false}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 12, + child: CustomPaint( + painter: _LegendIconPainter( + iconPaint: paint, + isDashed: isDashed, + isCircle: isCircle, + isCross: isCross, + ), + ), + ), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 11)), + ], + ); + } +} + +class _LegendIconPainter extends CustomPainter { + _LegendIconPainter({ + required Paint iconPaint, + this.isDashed = false, + this.isCircle = false, + this.isCross = false, + }) : _iconPaint = iconPaint; + + final Paint _iconPaint; + final bool isDashed; + final bool isCircle; + final bool isCross; + + @override + void paint(Canvas canvas, Size size) { + if (isCircle) { + canvas.drawCircle(Offset(size.width / 2, size.height / 2), 4, _iconPaint); + } else if (isCross) { + final cx = size.width / 2; + final cy = size.height / 2; + canvas.drawLine(Offset(cx - 4, cy), Offset(cx + 4, cy), _iconPaint); + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 4), _iconPaint); + } else if (isDashed) { + const dash = 4.0; + const gap = 3.0; + var dx = 0.0; + while (dx < size.width) { + final end = (dx + dash).clamp(0.0, size.width); + canvas.drawLine(Offset(dx, size.height / 2), + Offset(end, size.height / 2), _iconPaint); + dx = end + gap; + } + } else { + canvas.drawLine( + Offset(0, size.height / 2), + Offset(size.width, size.height / 2), + _iconPaint, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _ToolpathPainter extends CustomPainter { + _ToolpathPainter({ + required this.segments, + required this.progress, + required this.bounds, + required this.style, + }); + + final List segments; + final double progress; + final GcodeBounds? bounds; + final GcodeStyle style; + + static const _padding = 30.0; + static const _gridSpacing = 20.0; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final b = bounds ?? _computeBounds(); + final machineRangeX = max(b.maxX - b.minX, 1.0); + final machineRangeY = max(b.maxY - b.minY, 1.0); + final scaleX = (size.width - _padding * 2) / machineRangeX; + final scaleY = (size.height - _padding * 2) / machineRangeY; + final scale = min(scaleX, scaleY); + + final offsetX = + _padding + (size.width - _padding * 2 - machineRangeX * scale) / 2; + final offsetY = + _padding + (size.height - _padding * 2 - machineRangeY * scale) / 2; + + _drawGrid(canvas, size, b, scale, offsetX, offsetY); + _drawFullPath(canvas, b, scale, offsetX, offsetY); + _drawAnimatedPath(canvas, b, scale, offsetX, offsetY); + _drawToolHead(canvas, b, scale, offsetX, offsetY); + _drawOrigin(canvas, b, scale, offsetX, offsetY); + } + + void _drawGrid( + Canvas canvas, + Size size, + GcodeBounds bounds, + double scale, + double offsetX, + double offsetY, + ) { + final gridStep = _gridSpacing / scale; + + var x = (bounds.minX / gridStep).floor() * gridStep; + while (x <= bounds.maxX) { + final sx = offsetX + (x - bounds.minX) * scale; + canvas.drawLine( + Offset(sx, offsetY), + Offset(sx, offsetY + (bounds.maxY - bounds.minY) * scale), + style.gridPaint, + ); + x += gridStep; + } + + var y = (bounds.minY / gridStep).floor() * gridStep; + while (y <= bounds.maxY) { + final sy = offsetY + (bounds.maxY - y - bounds.minY) * scale; + canvas.drawLine( + Offset(offsetX, sy), + Offset(offsetX + (bounds.maxX - bounds.minX) * scale, sy), + style.gridPaint, + ); + y += gridStep; + } + } + + void _drawFullPath( + Canvas canvas, + GcodeBounds bounds, + double scale, + double offsetX, + double offsetY, + ) { + for (final seg in segments) { + final sx = offsetX + (seg.start.x - bounds.minX) * scale; + final sy = offsetY + (bounds.maxY - seg.start.y - bounds.minY) * scale; + final ex = offsetX + (seg.end.x - bounds.minX) * scale; + final ey = offsetY + (bounds.maxY - seg.end.y - bounds.minY) * scale; + + if (seg.type == GcodeSegmentType.rapid) { + _drawDashedLine( + canvas, + Offset(sx, sy), + Offset(ex, ey), + style.rapidMoveBgPaint, + ); + } else { + canvas.drawLine( + Offset(sx, sy), + Offset(ex, ey), + style.linearMoveBgPaint, + ); + } + } + } + + void _drawAnimatedPath( + Canvas canvas, + GcodeBounds bounds, + double scale, + double offsetX, + double offsetY, + ) { + if (progress <= 0 || segments.isEmpty) return; + + final totalSegments = segments.length; + final currentSegFloat = progress * totalSegments; + final currentSegIndex = currentSegFloat.floor().clamp(0, totalSegments - 1); + final localProgress = (currentSegFloat - currentSegIndex).clamp(0.0, 1.0); + + for (var i = 0; i <= currentSegIndex && i < totalSegments; i++) { + final seg = segments[i]; + final isCurrent = i == currentSegIndex; + + var endX = seg.end.x; + var endY = seg.end.y; + + if (isCurrent) { + endX = seg.start.x + (seg.end.x - seg.start.x) * localProgress; + endY = seg.start.y + (seg.end.y - seg.start.y) * localProgress; + } + + final sx = offsetX + (seg.start.x - bounds.minX) * scale; + final sy = offsetY + (bounds.maxY - seg.start.y - bounds.minY) * scale; + final ex = offsetX + (endX - bounds.minX) * scale; + final ey = offsetY + (bounds.maxY - endY - bounds.minY) * scale; + + if (seg.type == GcodeSegmentType.rapid) { + _drawDashedLine( + canvas, + Offset(sx, sy), + Offset(ex, ey), + style.rapidMovePaint, + ); + } else { + canvas.drawLine( + Offset(sx, sy), + Offset(ex, ey), + style.linearMovePaint, + ); + } + } + } + + void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) { + const dashLength = 7.0; + const gapLength = 5.0; + final delta = end - start; + final distance = delta.distance; + if (distance == 0) return; + + final direction = delta / distance; + var current = 0.0; + while (current < distance) { + final next = min(current + dashLength, distance); + canvas.drawLine( + start + direction * current, + start + direction * next, + paint, + ); + current = next + gapLength; + } + } + + void _drawToolHead( + Canvas canvas, + GcodeBounds bounds, + double scale, + double offsetX, + double offsetY, + ) { + if (progress <= 0 || segments.isEmpty) return; + + final totalSegments = segments.length; + final currentSegFloat = progress * totalSegments; + final currentSegIndex = currentSegFloat.floor().clamp(0, totalSegments - 1); + final localProgress = (currentSegFloat - currentSegIndex).clamp(0.0, 1.0); + final seg = segments[currentSegIndex]; + + final toolX = seg.start.x + (seg.end.x - seg.start.x) * localProgress; + final toolY = seg.start.y + (seg.end.y - seg.start.y) * localProgress; + + final sx = offsetX + (toolX - bounds.minX) * scale; + final sy = offsetY + (bounds.maxY - toolY - bounds.minY) * scale; + + canvas.drawCircle(Offset(sx, sy), 10, style.toolHeadGlowPaint); + canvas.drawCircle(Offset(sx, sy), 5, style.toolHeadPaint); + } + + void _drawOrigin( + Canvas canvas, + GcodeBounds bounds, + double scale, + double offsetX, + double offsetY, + ) { + final ox = offsetX + (0 - bounds.minX) * scale; + final oy = offsetY + (bounds.maxY - 0 - bounds.minY) * scale; + + const size = 6; + canvas.drawLine( + Offset(ox - size, oy), Offset(ox + size, oy), style.originPaint); + canvas.drawLine( + Offset(ox, oy - size), Offset(ox, oy + size), style.originPaint); + canvas.drawCircle(Offset(ox, oy), 2, style.originDotPaint); + } + + GcodeBounds _computeBounds() { + var minX = double.infinity; + var maxX = double.negativeInfinity; + var minY = double.infinity; + var maxY = double.negativeInfinity; + + for (final seg in segments) { + minX = min(minX, min(seg.start.x, seg.end.x)); + maxX = max(maxX, max(seg.start.x, seg.end.x)); + minY = min(minY, min(seg.start.y, seg.end.y)); + maxY = max(maxY, max(seg.start.y, seg.end.y)); + } + + return GcodeBounds( + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + ); + } + + @override + bool shouldRepaint(covariant _ToolpathPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.segments != segments || + oldDelegate.bounds != bounds || + oldDelegate.style != style; + } +} diff --git a/packages/gcode_core/lib/src/widgets/playback_controls.dart b/packages/gcode_core/lib/src/widgets/playback_controls.dart new file mode 100644 index 0000000..5597d2d --- /dev/null +++ b/packages/gcode_core/lib/src/widgets/playback_controls.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +class PlaybackControls extends StatelessWidget { + const PlaybackControls({ + super.key, + required this.isPlaying, + required this.progress, + required this.speedMultiplier, + required this.onPlay, + required this.onPause, + required this.onReset, + required this.onSeek, + required this.onSpeedChange, + }); + + final bool isPlaying; + final double progress; + final double speedMultiplier; + final VoidCallback onPlay; + final VoidCallback onPause; + final VoidCallback onReset; + final ValueChanged onSeek; + final ValueChanged onSpeedChange; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + children: [ + Row( + children: [ + IconButton.filled( + onPressed: isPlaying ? onPause : onPlay, + icon: + Icon(isPlaying ? Icons.pause : Icons.play_arrow, size: 20), + style: IconButton.styleFrom( + minimumSize: const Size(36, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + IconButton.filledTonal( + onPressed: onReset, + icon: const Icon(Icons.stop, size: 18), + style: IconButton.styleFrom( + minimumSize: const Size(36, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 4, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + value: progress, + onChanged: onSeek, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 50, + child: Text( + '${(progress * 100).toStringAsFixed(0)}%', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + ), + ), + ], + ), + Row( + children: [ + const Text('速度', style: TextStyle(fontSize: 12)), + const SizedBox(width: 8), + Expanded( + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 2, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: RoundSliderOverlayShape(overlayRadius: 10), + ), + child: Slider( + value: speedMultiplier, + min: 0.25, + max: 4.0, + divisions: 15, + label: '${speedMultiplier.toStringAsFixed(1)}x', + onChanged: onSpeedChange, + ), + ), + ), + SizedBox( + width: 40, + child: Text( + '${speedMultiplier.toStringAsFixed(1)}x', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/gcode_core/pubspec.yaml b/packages/gcode_core/pubspec.yaml new file mode 100644 index 0000000..3ac411b --- /dev/null +++ b/packages/gcode_core/pubspec.yaml @@ -0,0 +1,20 @@ +name: gcode_core +description: G-code parsing, line reading, toolpath building, and Flutter visualization widgets. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.6.0 <4.0.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^4.0.0 + +flutter: diff --git a/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart b/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart new file mode 100644 index 0000000..c27e9b4 --- /dev/null +++ b/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gcode_core/gcode_core.dart'; + +void main() { + group('GcodeReadlinePipeline', () { + test('reads string source line by line and builds segments', () async { + const source = ''' +G0 X0 Y0 +G1 X10 Y0 +G2 X10 Y10 +G1 X10 Y10 +'''; + + final pipeline = GcodeReadlinePipeline( + options: const GcodeReadlineOptions(snapshotBatchSize: 2), + ); + final snapshots = + await pipeline.load(const StringGcodeLineReader(source)).toList(); + + expect(snapshots.first.stage, GcodeLoadStage.reading); + expect(snapshots.last.stage, GcodeLoadStage.ready); + expect(snapshots.last.linesRead, 4); + expect(snapshots.last.commands, hasLength(3)); + expect(snapshots.last.errors, hasLength(1)); + expect(snapshots.last.segments, hasLength(2)); + expect(snapshots.last.segments.last.end.x, 10); + expect(snapshots.last.segments.last.end.y, 10); + }); + + test('reads gcode from file path', () async { + final file = File('${Directory.systemTemp.path}/gcode_readline_test.nc'); + await file.writeAsString('G0 X0 Y0\nG1 X5 Y5\n'); + addTearDown(() { + if (file.existsSync()) { + file.deleteSync(); + } + }); + + final pipeline = GcodeReadlinePipeline(); + final snapshots = + await pipeline.load(FileGcodeLineReader(file.path)).toList(); + + expect(snapshots.last.stage, GcodeLoadStage.ready); + expect(snapshots.last.linesRead, 2); + expect(snapshots.last.commands, hasLength(2)); + expect(snapshots.last.segments, hasLength(1)); + expect(snapshots.last.segments.single.end.x, 5); + expect(snapshots.last.segments.single.end.y, 5); + }); + + test('normalizes copied file paths before opening', () async { + final file = + File('${Directory.systemTemp.path}/gcode readline copied path.nc'); + await file.writeAsString('G0 X0 Y0\nG1 X3 Y4\n'); + addTearDown(() { + if (file.existsSync()) { + file.deleteSync(); + } + }); + + final pipeline = GcodeReadlinePipeline(); + final quotedPathSnapshots = + await pipeline.load(FileGcodeLineReader('"${file.path}"')).toList(); + final fileUriSnapshots = await pipeline + .load(FileGcodeLineReader(file.uri.toString())) + .toList(); + final escapedPathSnapshots = await pipeline + .load(FileGcodeLineReader(file.path.replaceAll(' ', r'\ '))) + .toList(); + + expect(quotedPathSnapshots.last.stage, GcodeLoadStage.ready); + expect(fileUriSnapshots.last.stage, GcodeLoadStage.ready); + expect(escapedPathSnapshots.last.stage, GcodeLoadStage.ready); + expect(escapedPathSnapshots.last.segments.single.end.x, 3); + expect(escapedPathSnapshots.last.segments.single.end.y, 4); + }); + }); +} diff --git a/packages/gcode_core/test/gcode_parser_test.dart b/packages/gcode_core/test/gcode_parser_test.dart new file mode 100644 index 0000000..1aac0a0 --- /dev/null +++ b/packages/gcode_core/test/gcode_parser_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gcode_core/gcode_core.dart'; + +void main() { + group('GcodeParser', () { + final parser = GcodeParser(); + + test('parses G0 uppercase', () { + final result = parser.parse('G0 X10 Y20'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G0'); + expect(result.commands.first.x, 10); + expect(result.commands.first.y, 20); + }); + + test('parses G1 uppercase', () { + final result = parser.parse('G1 X30 Y40 F500'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G1'); + expect(result.commands.first.x, 30); + expect(result.commands.first.y, 40); + expect(result.commands.first.feedRate, 500); + }); + + test('parses lowercase', () { + final result = parser.parse('g0 x10 y20'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G0'); + expect(result.commands.first.x, 10); + }); + + test('parses G00 and G01 aliases', () { + final r1 = parser.parse('G00 X5 Y5'); + expect(r1.commands.first.code, 'G0'); + + final r2 = parser.parse('G01 X5 Y5'); + expect(r2.commands.first.code, 'G1'); + }); + + test('parses semicolon comments', () { + final result = parser.parse('G1 X10 Y10 ; move to position'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.comment, 'move to position'); + }); + + test('parses parentheses comments', () { + final result = parser.parse('G1 X10 Y10 (comment here)'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.x, 10); + }); + + test('skips empty lines', () { + final result = parser.parse(''' + +G0 X0 Y0 + +G1 X10 Y10 + +'''); + expect(result.commands, hasLength(2)); + expect(result.errors, isEmpty); + }); + + test('rejects unsupported G2', () { + final result = parser.parse('G2 X10 Y10 I5 J5'); + expect(result.errors, hasLength(1)); + expect(result.errors.first.message, contains('Unsupported code')); + }); + + test('rejects malformed X value', () { + final result = parser.parse('G1 Xabc Y10'); + expect(result.errors, hasLength(1)); + expect(result.errors.first.message, contains('Malformed parameter')); + }); + + test('rejects parameter with trailing junk', () { + final result = parser.parse('G1 X10abc Y10'); + expect(result.commands, isEmpty); + expect(result.errors, hasLength(1)); + expect(result.errors.first.message, contains('Malformed parameter')); + }); + + test('handles negative coordinates', () { + final result = parser.parse('G1 X-10.5 Y-20.3'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.x, -10.5); + expect(result.commands.first.y, -20.3); + }); + + test('handles leading decimal coordinates', () { + final result = parser.parse('G1 X.5 Y-.25'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.x, 0.5); + expect(result.commands.first.y, -0.25); + }); + + test('multi-line parse with errors preserves valid commands', () { + final result = parser.parse(''' +G0 X0 Y0 +G2 X10 Y10 +G1 X20 Y20 +'''); + expect(result.commands, hasLength(2)); + expect(result.errors, hasLength(1)); + expect(result.errors.first.lineNumber, 2); + }); + + test('parses G90 as valid command', () { + final result = parser.parse('G90'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G90'); + expect(result.errors, isEmpty); + }); + + test('parses G91 as valid command', () { + final result = parser.parse('G91'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G91'); + expect(result.errors, isEmpty); + }); + + test('parses G90 with comment only', () { + final result = parser.parse('G90 ; set absolute mode'); + expect(result.commands, hasLength(1)); + expect(result.commands.first.code, 'G90'); + expect(result.commands.first.comment, 'set absolute mode'); + }); + + test('parses sequential G90 and G91', () { + final result = parser.parse('G90\nG91\nG1 X10 Y10'); + expect(result.commands, hasLength(3)); + expect(result.commands[0].code, 'G90'); + expect(result.commands[1].code, 'G91'); + expect(result.commands[2].code, 'G1'); + }); + + test('parseRecord preserves readline metadata', () { + final parsed = parser.parseRecord( + const GcodeLineRecord( + lineNumber: 12, + rawLine: 'G1 X20 Y30', + byteOffset: 128, + ), + ); + + expect(parsed.kind, ParsedGcodeLineKind.command); + expect(parsed.record.lineNumber, 12); + expect(parsed.record.byteOffset, 128); + expect(parsed.command?.lineNumber, 12); + expect(parsed.command?.x, 20); + }); + }); +} diff --git a/packages/gcode_core/test/toolpath_builder_test.dart b/packages/gcode_core/test/toolpath_builder_test.dart new file mode 100644 index 0000000..2bfa5da --- /dev/null +++ b/packages/gcode_core/test/toolpath_builder_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gcode_core/gcode_core.dart'; + +void main() { + group('ToolpathBuilder', () { + test('builds linear segments from G1 commands', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G1 X10 Y0', + code: 'G1', + params: {'X': 10, 'Y': 0}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + + expect(segments, hasLength(2)); + expect(segments[0].type, GcodeSegmentType.linear); + expect(segments[0].start.x, 0); + expect(segments[0].start.y, 0); + expect(segments[0].end.x, 10); + expect(segments[0].end.y, 0); + expect(segments[1].end.x, 10); + expect(segments[1].end.y, 10); + }); + + test('builds rapid segments from G0 commands', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G0 X50 Y50', + code: 'G0', + params: {'X': 50, 'Y': 50}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + + expect(segments, hasLength(1)); + expect(segments[0].type, GcodeSegmentType.rapid); + }); + + test('keeps previous coordinate when X/Y omitted', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G1 X10 Y20', + code: 'G1', + params: {'X': 10, 'Y': 20}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G1 X30', + code: 'G1', + params: {'X': 30}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + + expect(segments, hasLength(2)); + expect(segments[1].start.y, 20); // kept from previous + expect(segments[1].end.x, 30); + expect(segments[1].end.y, 20); + }); + + test('no segment when no movement', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G1 X0 Y0', + code: 'G1', + params: {'X': 0, 'Y': 0}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + expect(segments, isEmpty); + }); + + test('empty commands returns empty segments', () { + final segments = ToolpathBuilder.build([]); + expect(segments, isEmpty); + }); + + test('G90 sets absolute mode', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G90', + code: 'G90', + params: {}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + expect(segments, hasLength(1)); + expect(segments[0].end.x, 10); + expect(segments[0].end.y, 10); + }); + + test('G91 sets relative mode', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G91', + code: 'G91', + params: {}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + const GcodeCommand( + lineNumber: 3, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + expect(segments, hasLength(2)); + expect(segments[0].end.x, 10); + expect(segments[0].end.y, 10); + expect(segments[1].start.x, 10); + expect(segments[1].start.y, 10); + expect(segments[1].end.x, 20); + expect(segments[1].end.y, 20); + }); + + test('mode changes do not create segments', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G90', + code: 'G90', + params: {}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G91', + code: 'G91', + params: {}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + expect(segments, isEmpty); + }); + + test('mixed G90/G91 sequence', () { + final commands = [ + const GcodeCommand( + lineNumber: 1, + rawLine: 'G90', + code: 'G90', + params: {}, + ), + const GcodeCommand( + lineNumber: 2, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + const GcodeCommand( + lineNumber: 3, + rawLine: 'G91', + code: 'G91', + params: {}, + ), + const GcodeCommand( + lineNumber: 4, + rawLine: 'G1 X10 Y10', + code: 'G1', + params: {'X': 10, 'Y': 10}, + ), + ]; + + final segments = ToolpathBuilder.build(commands); + expect(segments, hasLength(2)); + expect(segments[0].end.x, 10); // absolute: 0 -> 10 + expect(segments[0].end.y, 10); + expect(segments[1].end.x, 20); // relative: 10 + 10 + expect(segments[1].end.y, 20); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index effa07a..051e521 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.7.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.14" desktop_multi_window: dependency: "direct main" description: @@ -161,13 +177,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" - file_picker_bridge: - dependency: "direct main" + fixnum: + dependency: transitive description: - path: "../file_picker_bridge" - relative: true - source: path - version: "0.1.0" + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -181,13 +198,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "8.1.6" - flutter_ioc_core: - dependency: "direct main" - description: - path: "../flutter_ioc_core" - relative: true - source: path - version: "0.1.0" flutter_lints: dependency: "direct dev" description: @@ -204,13 +214,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.6.1" - flutter_study_learning: - dependency: "direct main" - description: - path: "../flutter_study_learning" - relative: true - source: path - version: "0.1.0" flutter_svg: dependency: "direct main" description: @@ -232,17 +235,10 @@ packages: flutterguard_cli: dependency: "direct dev" description: - path: "../flutterguard/packages/flutterguard_cli" - relative: true - source: path - version: "0.1.1" - gcode_core: - dependency: "direct main" - description: - path: "../gcode_core" + path: "../flutterguard" relative: true source: path - version: "0.1.0" + version: "0.7.1" glob: dependency: transitive description: @@ -275,6 +271,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.8.0" leak_tracker: dependency: transitive description: @@ -331,14 +335,78 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.flutter-io.cn" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -363,6 +431,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -395,6 +479,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" provider: dependency: "direct main" description: @@ -419,6 +511,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.6.1" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.6" sky_engine: dependency: transitive description: flutter @@ -464,6 +564,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: @@ -476,10 +584,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.11" two_dimensional_scrollables: dependency: "direct main" description: @@ -496,6 +604,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" usb_serial: dependency: "direct main" description: @@ -504,6 +628,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" vector_graphics: dependency: transitive description: @@ -544,6 +676,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" watcher: dependency: transitive description: @@ -593,5 +741,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 39bdf0b..eb32c59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,26 @@ name: main_app description: A Flutter app shell. publish_to: 'none' -version: 1.0.0+1 +version: 1.1.0 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ^3.11.5 + +workspace: + - packages/gcode_core + - packages/flutter_study_learning + - packages/file_picker_bridge + - packages/flutter_ioc_core dependencies: gcode_core: - path: ../gcode_core + path: packages/gcode_core flutter_study_learning: - path: ../flutter_study_learning + path: packages/flutter_study_learning file_picker_bridge: - path: ../file_picker_bridge + path: packages/file_picker_bridge flutter_ioc_core: - path: ../flutter_ioc_core + path: packages/flutter_ioc_core flutter: sdk: flutter go_router: ^14.2.0 @@ -29,13 +35,16 @@ dependencies: usb_serial: ^0.5.0 device_info_plus: ^10.1.2 desktop_multi_window: ^0.3.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 flutterguard_cli: - path: ../flutterguard/packages/flutterguard_cli + path: ../flutterguard flutter: uses-material-design: true diff --git a/test/download_animation/download_animation_page_test.dart b/test/download_animation/download_animation_page_test.dart index ee0af31..c855c32 100644 --- a/test/download_animation/download_animation_page_test.dart +++ b/test/download_animation/download_animation_page_test.dart @@ -4,13 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:main_app/modules/ui/download_animation/pages/download_animation_page.dart'; void main() { - testWidgets('DownloadAnimationPage renders teaching components', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: DownloadAnimationPage(), - ), - ); + testWidgets('DownloadAnimationPage renders teaching components', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: DownloadAnimationPage())); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/test/gcode_visualizer/gcode_visualizer_page_test.dart b/test/gcode_visualizer/gcode_visualizer_page_test.dart index e425236..8172e67 100644 --- a/test/gcode_visualizer/gcode_visualizer_page_test.dart +++ b/test/gcode_visualizer/gcode_visualizer_page_test.dart @@ -4,9 +4,7 @@ import 'package:main_app/modules/ui/gcode_visualizer/pages/gcode_visualizer_page void main() { testWidgets('GcodeVisualizerPage renders key elements', (tester) async { - await tester.pumpWidget( - const MaterialApp(home: GcodeVisualizerPage()), - ); + await tester.pumpWidget(const MaterialApp(home: GcodeVisualizerPage())); await tester.pump(); diff --git a/test/modules/platform/online_video_player/online_video_player_test.dart b/test/modules/platform/online_video_player/online_video_player_test.dart new file mode 100644 index 0000000..1ac5961 --- /dev/null +++ b/test/modules/platform/online_video_player/online_video_player_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:main_app/modules/platform/online_video_player/module_root.dart'; +import 'package:main_app/modules/platform/online_video_player/state/media_kit_player_adapter.dart'; +import 'package:main_app/modules/platform/online_video_player/widgets/video_player_controls.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +void main() { + testWidgets('controls render playback, seek, rate and volume controls', ( + tester, + ) async { + final adapter = FakeVideoPlayerAdapter(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: VideoPlayerControls(adapter: adapter)), + ), + ); + + expect(find.byKey(const Key('video-play-pause')), findsOneWidget); + expect(find.byKey(const Key('video-seek')), findsOneWidget); + expect(find.byKey(const Key('video-rate')), findsOneWidget); + expect(find.byKey(const Key('video-volume')), findsOneWidget); + expect(find.text('00:00 / 02:00'), findsOneWidget); + expect(find.text('100%'), findsOneWidget); + }); + + testWidgets('play pause button follows adapter state', (tester) async { + final adapter = FakeVideoPlayerAdapter(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: VideoPlayerControls(adapter: adapter)), + ), + ); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + await tester.tap(find.byKey(const Key('video-play-pause'))); + await tester.pump(); + expect(find.byIcon(Icons.pause), findsOneWidget); + + await tester.tap(find.byKey(const Key('video-play-pause'))); + await tester.pump(); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('module shows error placeholder without network access', ( + tester, + ) async { + final adapter = FakeVideoPlayerAdapter(failOnOpen: true); + + await tester.pumpWidget( + MaterialApp( + home: MyHomePage(title: '在线视频播放', adapter: adapter), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('video-error-placeholder')), findsOneWidget); + expect(find.text('视频加载失败,请检查网络后重试'), findsOneWidget); + expect(adapter.openCount, 1); + }); +} + +class FakeVideoPlayerAdapter implements VideoPlayerAdapter { + FakeVideoPlayerAdapter({this.failOnOpen = false}); + + final bool failOnOpen; + int openCount = 0; + + @override + final ValueNotifier uiState = ValueNotifier( + PlayerUiState.paused, + ); + + @override + final ValueNotifier position = ValueNotifier(Duration.zero); + + @override + final ValueNotifier duration = ValueNotifier( + const Duration(minutes: 2), + ); + + @override + final ValueNotifier volume = ValueNotifier(1); + + @override + final ValueNotifier rate = ValueNotifier(1); + + @override + VideoController? get videoController => null; + + @override + Future openAndPlay() async { + openCount++; + uiState.value = failOnOpen ? PlayerUiState.error : PlayerUiState.playing; + } + + @override + Future pause() async => uiState.value = PlayerUiState.paused; + + @override + Future play() async => uiState.value = PlayerUiState.playing; + + @override + Future togglePlayPause() async { + uiState.value = uiState.value == PlayerUiState.playing + ? PlayerUiState.paused + : PlayerUiState.playing; + } + + @override + Future seek(Duration value) async => position.value = value; + + @override + Future setRate(double value) async => rate.value = value; + + @override + Future setVolume(double value) async => volume.value = value; + + @override + void dispose() { + uiState.dispose(); + position.dispose(); + duration.dispose(); + volume.dispose(); + rate.dispose(); + } +} diff --git a/test/overlay_follow_compare/overlay_compare_page_test.dart b/test/overlay_follow_compare/overlay_compare_page_test.dart index 4d694eb..a8a8ad8 100644 --- a/test/overlay_follow_compare/overlay_compare_page_test.dart +++ b/test/overlay_follow_compare/overlay_compare_page_test.dart @@ -5,11 +5,7 @@ import 'package:main_app/modules/popup_table/overlay_follow_compare/module_root. void main() { testWidgets('OverlayComparePage renders teaching components', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: OverlayComparePage(), - ), - ); + await tester.pumpWidget(const MaterialApp(home: OverlayComparePage())); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/test/popup_widgets/popup_widgets_page_test.dart b/test/popup_widgets/popup_widgets_page_test.dart index 0cee2eb..6f87bb4 100644 --- a/test/popup_widgets/popup_widgets_page_test.dart +++ b/test/popup_widgets/popup_widgets_page_test.dart @@ -6,11 +6,7 @@ import 'package:main_app/modules/popup_table/popup_widgets/module_root.dart'; void main() { testWidgets('PopWidgetEntry renders module page', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: PopWidgetEntry(), - ), - ); + await tester.pumpWidget(const MaterialApp(home: PopWidgetEntry())); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -21,9 +17,7 @@ void main() { testWidgets('PopDemoHomePage renders teaching components', (tester) async { await tester.pumpWidget( - const MaterialApp( - home: PopDemoHomePage(title: 'Flutter 弹窗学习'), - ), + const MaterialApp(home: PopDemoHomePage(title: 'Flutter 弹窗学习')), ); await tester.pump(); @@ -39,9 +33,7 @@ void main() { testWidgets('PopDemoHomePage FAB toggles bottom bar', (tester) async { await tester.pumpWidget( - const MaterialApp( - home: PopDemoHomePage(title: 'Flutter 弹窗学习'), - ), + const MaterialApp(home: PopDemoHomePage(title: 'Flutter 弹窗学习')), ); await tester.pump(); @@ -56,12 +48,11 @@ void main() { expect(find.text('这是一个持久化底部工具条,你可以手动关闭。'), findsOneWidget); }); - testWidgets('PopDemoHomePage toolbar shows date/time picker menu', - (tester) async { + testWidgets('PopDemoHomePage toolbar shows date/time picker menu', ( + tester, + ) async { await tester.pumpWidget( - const MaterialApp( - home: PopDemoHomePage(title: 'Flutter 弹窗学习'), - ), + const MaterialApp(home: PopDemoHomePage(title: 'Flutter 弹窗学习')), ); await tester.pump(); diff --git a/test/shared/module_catalog_utils_test.dart b/test/shared/module_catalog_utils_test.dart new file mode 100644 index 0000000..a34728e --- /dev/null +++ b/test/shared/module_catalog_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:main_app/module_registry/module_catalog_utils.dart'; +import 'package:main_app/module_registry/module_category.dart'; +import 'package:main_app/module_registry/module_entry.dart'; + +void main() { + ModuleEntry createModule({ + required String path, + required ModuleCategory category, + List routes = const [], + }) { + 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, + ); + } + + test('filters modules without changing catalog order', () { + final modules = [ + createModule(path: '/basic-a', category: ModuleCategory.basic), + 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']); + }); + + 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'); + }); +} diff --git a/test/stream_subscription/stream_demo_page_test.dart b/test/stream_subscription/stream_demo_page_test.dart index 5f2b353..2fc3e0b 100644 --- a/test/stream_subscription/stream_demo_page_test.dart +++ b/test/stream_subscription/stream_demo_page_test.dart @@ -5,11 +5,7 @@ import 'package:main_app/modules/async/stream_subscription/pages/stream_demo_pag void main() { testWidgets('StreamDemoPage renders teaching components', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: StreamDemoPage(), - ), - ); + await tester.pumpWidget(const MaterialApp(home: StreamDemoPage())); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/tool/bootstrap.sh b/tool/bootstrap.sh new file mode 100755 index 0000000..476f970 --- /dev/null +++ b/tool/bootstrap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bootstrap.sh — 环境校验 + 依赖获取 + hooks 启用 +# 不修改业务代码,仅初始化开发环境 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "=== Flutter Study 环境自举 ===" +echo "" + +# 1. 环境校验 +echo "--- 1/4 环境校验 ---" +bash tool/check_environment.sh + +# 2. 依赖获取 +echo "" +echo "--- 2/4 依赖获取 ---" +flutter pub get +echo " 依赖获取完成。" + +# 3. 启用 Git hooks +echo "" +echo "--- 3/4 Git hooks ---" +if [ -d ".githooks" ]; then + git config core.hooksPath .githooks + echo " hooks 路径已设置: .githooks" +else + echo " ⚠ .githooks 目录不存在,跳过 hooks 配置" +fi + +# 4. 最小 smoke check +echo "" +echo "--- 4/4 Smoke check ---" +if dart analyze lib/ 2>&1 | grep -q "No issues found"; then + echo " ✓ dart analyze: 通过" +else + local issues + issues=$(flutter analyze 2>&1 | grep -c "error •" || true) + if [ "$issues" -gt 0 ]; then + echo " ✗ flutter analyze: 发现 $issues 个 error" + exit 1 + else + echo " ✓ flutter analyze: 无 error (info/warning 可接受)" + fi +fi + +echo "" +echo "=== 自举完成 ===" +echo "" +echo "后续步骤:" +echo " bash tool/quality_gate.sh # 运行全量质量门禁" +echo " flutter run # 启动应用" diff --git a/tool/check_environment.sh b/tool/check_environment.sh new file mode 100755 index 0000000..8bd4c4e --- /dev/null +++ b/tool/check_environment.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# check_environment.sh — 验证开发环境组件 +# 输出: 版本信息、缺失组件、修复建议 +# 退出码: 0 = OK, 非0 = 有缺失 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +PASS=0 +WARN=0 +FAIL=0 + +check_cmd() { + local name="$1" + local cmd="$2" + local version_flag="${3:---version}" + local min_version="${4:-}" + + if command -v "$cmd" &>/dev/null; then + local version + version=$("$cmd" $version_flag 2>&1 | head -1 || true) + echo -e " ${GREEN}✓${NC} $name: $version" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $name: NOT FOUND" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== Flutter Study 环境检查 ===" +echo "" + +echo "--- 核心工具链 ---" +check_cmd "Flutter" flutter "--version" +check_cmd "Dart" dart "--version" +check_cmd "Node.js" node "--version" +check_cmd "npm" npm "--version" +echo "" + +echo "--- 平台 SDK (按需) ---" +if command -v xcodebuild &>/dev/null; then + echo -e " ${GREEN}✓${NC} Xcode: $(xcodebuild -version 2>&1 | head -1)" + PASS=$((PASS + 1)) +else + echo -e " ${YELLOW}⚠${NC} Xcode: NOT FOUND (仅 macOS/iOS 构建需要)" + WARN=$((WARN + 1)) +fi + +if command -v java &>/dev/null; then + echo -e " ${GREEN}✓${NC} Java: $(java -version 2>&1 | head -1)" + PASS=$((PASS + 1)) +else + echo -e " ${YELLOW}⚠${NC} Java: NOT FOUND (仅 Android 构建需要)" + WARN=$((WARN + 1)) +fi + +if [ -d "$ANDROID_HOME" ] || [ -d "$ANDROID_SDK_ROOT" ]; then + echo -e " ${GREEN}✓${NC} Android SDK: found" + PASS=$((PASS + 1)) +else + echo -e " ${YELLOW}⚠${NC} Android SDK: NOT FOUND (仅 Android 构建需要)" + WARN=$((WARN + 1)) +fi +echo "" + +echo "--- 项目工具 ---" +if [ -f "pubspec.yaml" ]; then + echo -e " ${GREEN}✓${NC} pubspec.yaml: found" + PASS=$((PASS + 1)) +else + echo -e " ${RED}✗${NC} pubspec.yaml: NOT FOUND (不在项目根目录?)" + FAIL=$((FAIL + 1)) +fi + +if [ -f "pubspec.lock" ]; then + echo -e " ${GREEN}✓${NC} pubspec.lock: found" + PASS=$((PASS + 1)) +else + echo -e " ${YELLOW}⚠${NC} pubspec.lock: NOT FOUND (需要 flutter pub get)" + WARN=$((WARN + 1)) +fi + +echo "" +echo "--- 汇总 ---" +echo -e " 通过: ${GREEN}$PASS${NC}" +echo -e " 警告: ${YELLOW}$WARN${NC}" +echo -e " 失败: ${RED}$FAIL${NC}" + +if [ "$FAIL" -gt 0 ]; then + echo "" + echo "修复建议:" + echo " 1. 安装缺失的命令行工具" + echo " 2. 运行 bash tool/bootstrap.sh 初始化项目依赖" + exit 1 +fi + +echo "" +echo "环境检查通过。" diff --git a/tool/check_generated_docs.sh b/tool/check_generated_docs.sh new file mode 100755 index 0000000..1de4fe8 --- /dev/null +++ b/tool/check_generated_docs.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# check_generated_docs.sh — 检测生成物是否漂移 +# 生成 → git diff → 有差异则失败 +# CI 中通过此脚本确保提交了最新生成物 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "=== 文档漂移检测 ===" + +# 1. 运行生成器 +echo "--- 1/3 生成 Agent 文档 ---" +bash tool/generate_harness_ai_analysis.sh + +# 2. 检测生成物漂移 +echo "--- 2/3 检测漂移 ---" +GENERATED_FILES=( + "AI_PROJECT_CONTEXT.md" + "REFACTOR_PLAN.md" + "lib/AI_MODULE_INDEX.md" + "packages/gcode_core/AI_ANALYSIS.md" + "packages/flutter_study_learning/AI_ANALYSIS.md" + "packages/file_picker_bridge/AI_ANALYSIS.md" + "packages/flutter_ioc_core/AI_ANALYSIS.md" +) + +if ! git diff --exit-code -- "${GENERATED_FILES[@]}" 2>/dev/null; then + echo "" + echo "✗ 检测到生成物漂移!" + echo " 上述文件在生成后与已提交版本不一致。" + echo " 请执行 'bash tool/generate_harness_ai_analysis.sh' 后提交更新。" + exit 1 +fi + +# 3. 验证 JSON 合法性 +echo "--- 3/3 JSON 合法性 ---" +node tool/validate_agent_docs.js + +echo "" +echo "✓ 生成物无漂移,所有文档合法。" diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index bacfb63..6e2000a 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -7,8 +7,8 @@ const contracts = { no_natural_language: true, index_only: true, max_index_depth: 2, - doc_consumer: 'vibecoding', - doc_mode: 'harness', + doc_consumer: 'coding_agent', + doc_mode: 'machine_contract', update_required_on_file_change: true, import_direction_enforced: true, }; @@ -31,6 +31,7 @@ const modules = [ ['popup_table', 'overlay_follow_compare', '/overlay-compare', 'ready', ['flutter_study_learning', 'module_registry']], ['platform', 'dio_interceptor', '/dio-interceptor', 'ready', ['flutter_study_learning', 'dio', 'module_registry', 'go_router']], ['platform', 'usb_detector', '/usb-detector', 'ready', ['flutter_study_learning', 'usb_serial', 'device_info_plus', 'module_registry']], + ['platform', 'online_video_player', '/online-video-player', 'ready', ['flutter_study_learning', 'media_kit', 'media_kit_video', 'module_registry']], ]; const categoryMeta = { @@ -39,9 +40,61 @@ const categoryMeta = { state: [['status_management', 'flutter_ioc'], ['state_management'], ['provider', 'flutter_riverpod', 'flutter_bloc', 'flutter_ioc_core']], ui: [['gcode_visualizer', 'adsorption_line', 'download_animation'], ['ui_animation_custom_paint'], ['provider', 'gcode_core', 'file_picker_bridge', 'flutter_study_learning']], popup_table: [['popup_widgets', 'popup_list_interaction', 'scroll_table', 'overlay_follow_compare'], ['popup_overlay_table'], ['module_registry', 'flutter_study_learning', 'two_dimensional_scrollables']], - platform: [['dio_interceptor', 'usb_detector'], ['network_platform'], ['dio', 'usb_serial', 'device_info_plus', 'flutter_study_learning']], + platform: [['dio_interceptor', 'usb_detector', 'online_video_player'], ['network_platform'], ['dio', 'usb_serial', 'device_info_plus', 'media_kit', 'media_kit_video', 'flutter_study_learning']], }; +const flutterGuardDependency = { + package: 'flutterguard_cli', + source: 'git', + url: 'https://github.com/lizy-coding/flutterguard.git', + ref: '9f9be84a73dc4b99a956a8529b8c334849566b03', + immutable: true, + lock_status: 'active', +}; + +const workspacePackages = [ + { + name: 'gcode_core', + kind: 'flutter_package', + path: 'packages/gcode_core', + entrypoints: ['lib/gcode_core.dart'], + owns: ['gcode_parsing', 'line_reading', 'toolpath_building', 'flutter_visualization_widgets'], + depends: ['flutter_sdk'], + validation: ['flutter pub get', 'flutter analyze', 'flutter test'], + test_status: 'configured', + }, + { + name: 'flutter_study_learning', + kind: 'flutter_package', + path: 'packages/flutter_study_learning', + entrypoints: ['lib/flutter_study_learning.dart'], + owns: ['learning_scaffold_widgets', 'teaching_ui_components'], + depends: ['flutter_sdk'], + validation: ['flutter pub get', 'flutter analyze', 'flutter test'], + test_status: 'configured', + }, + { + name: 'file_picker_bridge', + kind: 'flutter_bridge_package', + path: 'packages/file_picker_bridge', + entrypoints: ['lib/file_picker_bridge.dart'], + owns: ['file_picker_api', 'method_channel_client'], + depends: ['flutter_sdk'], + validation: ['flutter pub get', 'flutter analyze', 'flutter test'], + test_status: 'configured', + }, + { + name: 'flutter_ioc_core', + kind: 'dart_package', + path: 'packages/flutter_ioc_core', + entrypoints: ['lib/flutter_ioc_core.dart'], + owns: ['ioc_container', 'registration_lifetimes', 'scoped_resolution'], + depends: [], + validation: ['dart pub get', 'dart analyze', 'dart test'], + test_status: 'configured', + }, +]; + function writeJson(rel, value) { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -84,12 +137,20 @@ function writeIndex({ function writeSchema() { writeJson('AI_ANALYSIS_SCHEMA.json', { - schema: 'vibecoding.harness.ai_analysis_schema.v1', + schema: 'flutter_study.agent_docs.schema.v2', syntax: 'json_config', prose: 'forbidden', markdown: 'forbidden', + generated_by: 'tool/generate_agent_indexes.js', + documents: { + project_context: 'AI_PROJECT_CONTEXT.md', + refactor_plan: 'REFACTOR_PLAN.md', + module_index: 'lib/AI_MODULE_INDEX.md', + analysis_glob: '**/AI_ANALYSIS.md', + }, levels: { workspace: ['AI_ANALYSIS.md'], + package_contract: workspacePackages.map(({ path: packagePath }) => `${packagePath}/AI_ANALYSIS.md`), section: [ 'lib/AI_ANALYSIS.md', 'lib/app/AI_ANALYSIS.md', @@ -116,14 +177,201 @@ function writeSchema() { no_natural_language: true, index_only: true, max_index_depth: 2, - doc_consumer: 'vibecoding', - doc_mode: 'harness', + doc_consumer: 'coding_agent', + doc_mode: 'machine_contract', }, module_contract_policy: { keep_for_module_rule: true, content: ['route', 'category', 'status', 'entrypoints', 'analysis_parent'], avoid: ['class_descriptions', 'long_file_inventory', 'natural_language_notes'], }, + package_contract_policy: { + required_for_workspace_member: true, + content: ['package_type', 'workspace', 'entrypoints', 'owns', 'depends', 'validation', 'test_status'], + avoid: ['platform_claims_not_proven_by_manifest', 'natural_language_notes'], + }, + }); +} + +function writeProjectContext() { + writeJson('AI_PROJECT_CONTEXT.md', { + schema: 'flutter_study.agent_docs.project_context.v1', + consumer: 'coding_agent', + package: { + name: 'main_app', + type: 'flutter_modular_learning_app', + sdk: ['flutter_3', 'dart_3'], + }, + platform: { + current_hosts: ['macos', 'windows'], + next_host: 'android', + target_hosts: ['android', 'ios', 'macos', 'windows'], + }, + entrypoints: { + process: 'lib/main.dart', + 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', + }, + repository: { + layout: 'pub_workspace', + workspace_root: '.', + members: workspacePackages.map(({ path: packagePath }) => packagePath), + resolution_status: 'active', + resolution_blocker: 'none', + }, + internal_packages: workspacePackages.map(({ name, kind, path: packagePath, entrypoints }) => ({ + name, + type: kind, + path: packagePath, + entrypoint: entrypoints[0], + })), + external_tools: [flutterGuardDependency], + layers: [ + { + id: 'app', + path: 'lib/app', + owns: ['host_bootstrap', 'app_shell', 'navigation_policy', 'route_composition'], + may_depend_on: ['module_registry', 'shared', 'modules'], + }, + { + id: 'module_registry', + path: 'lib/module_registry', + owns: ['module_metadata', 'catalog_operations'], + may_depend_on: ['flutter', 'go_router'], + }, + { + id: 'shared', + path: 'lib/shared', + owns: ['business_neutral_capabilities', 'platform_boundaries'], + forbidden_dependencies: ['app', 'modules'], + }, + { + id: 'modules', + path: 'lib/modules/{category}/{module}', + owns: ['learning_ui', 'module_state', 'module_domain', 'module_data'], + forbidden_dependencies: ['other_modules'], + }, + ], + module_contract: { + required_files: ['module_entry.dart', 'AI_ANALYSIS.md'], + required_registration: 'lib/app/router/app_route_table.dart', + required_metadata: ['category', 'difficulty', 'concepts', 'estimatedMinutes', 'status', 'subtitle'], + required_learning_dependency: 'flutter_study_learning', + route_path_style: 'kebab_case', + directory_style: 'snake_case', + }, + platform_rules: { + router_platform_api: 'forbidden', + module_host_navigation: 'forbidden', + desktop_window_policy: 'lib/app/category_navigation.dart', + platform_capability_contract: 'business_neutral_interface', + }, + change_protocol: { + pre_read: ['AI_PROJECT_CONTEXT.md', 'REFACTOR_PLAN.md', '{target}/AI_ANALYSIS.md'], + update_source: ['tool/generate_agent_indexes.js'], + generate: 'bash tool/generate_harness_ai_analysis.sh', + validate: [ + 'bash tool/generate_harness_ai_analysis.sh', + 'dart format .', + 'flutter analyze', + 'dart run flutterguard_cli:flutterguard scan . --fail-on high', + ], + }, + }); +} + +function writeRefactorPlan() { + writeJson('REFACTOR_PLAN.md', { + schema: 'flutter_study.agent_docs.refactor_plan.v1', + objective: 'android_readiness_after_architecture_convergence', + active_phase: 'agent_managed', + completed_milestones: [ + 'directory_layers', + 'shared_package_extraction', + 'module_analysis_coverage', + 'app_navigation_boundary', + 'host_bootstrap_boundary', + 'workspace_package_import', + 'agent_takeover_ready', + ], + dependency_migration: { + layout: 'pub_workspace', + internal_packages: workspacePackages.map(({ path: packagePath }) => packagePath), + workspace_resolution_status: 'active', + workspace_resolution_blocker: 'none', + external_tool: flutterGuardDependency, + }, + work_queue: [ + { + id: 'module_platform_contract', + priority: 1, + status: 'pending', + changes: ['ModuleEntry.platform_support', 'ModuleHomePage.availability_state'], + acceptance: ['catalog_platform_metadata_complete', 'unsupported_module_state_visible'], + }, + { + id: 'platform_plugin_audit', + priority: 2, + status: 'pending', + targets: ['desktop_multi_window', 'file_picker_bridge', 'usb_serial', 'device_info_plus'], + acceptance: ['android_support_matrix', 'unsupported_fallbacks'], + }, + { + id: 'usb_platform_boundary', + priority: 3, + status: 'pending', + targets: ['lib/modules/platform/usb_detector'], + acceptance: ['no_windows_hardcode', 'android_system_info', 'error_branch_test'], + }, + { + id: 'mobile_layout_baseline', + priority: 4, + status: 'pending', + viewport_width_dp: 360, + targets: ['module_home', 'category_home', 'ready_modules', 'recommended_modules'], + acceptance: ['no_overflow', 'safe_area', 'keyboard_avoidance', 'touch_targets'], + }, + { + id: 'android_host', + priority: 5, + status: 'blocked_by_dependencies', + depends_on: ['module_platform_contract', 'platform_plugin_audit', 'mobile_layout_baseline'], + acceptance: ['android_directory', 'manifest_capabilities', 'debug_apk', 'emulator_smoke'], + }, + ], + quality_gate: [ + 'node tool/validate_agent_docs.js', + 'dart format .', + 'flutter analyze:no_error', + 'flutterguard:no_high', + 'logic_change:targeted_test', + 'teaching_ui_change:visual_evidence', + ], + deferred_queue: [ + 'popup_widgets_decomposition', + 'widget_test_coverage', + 'flutterguard_med_reduction', + 'recommended_module_visual_evidence', + ], + }); +} + +function writeModuleIndex() { + writeJson('lib/AI_MODULE_INDEX.md', { + schema: 'flutter_study.agent_docs.module_index.v1', + registry: 'lib/app/router/app_route_table.dart', + count: modules.length, + modules: modules.map(([category, module, route, status, depends]) => ({ + id: module, + category, + path: `lib/modules/${category}/${module}`, + route, + status, + depends, + analysis: `lib/modules/${category}/${module}/AI_ANALYSIS.md`, + })), }); } @@ -132,17 +380,30 @@ function writeRootIndexes() { rel: 'AI_ANALYSIS.md', id: 'flutter_study.root', kind: 'workspace_index', - entrypoints: ['lib/main.dart', 'lib/app/app.dart', 'lib/app/router/app_route_table.dart'], + entrypoints: ['lib/main.dart', 'lib/app/app_bootstrap.dart', 'lib/app/app.dart', 'lib/app/router/app_route_table.dart'], owns: ['app_shell', 'module_registry', 'shared_capabilities', 'learning_modules', 'host_integrations'], - depends: ['../gcode_core', '../flutter_study_learning', '../file_picker_bridge', '../flutter_ioc_core', '../flutterguard/packages/flutterguard_cli'], - children: ['lib/AI_ANALYSIS.md', 'lib/app/AI_ANALYSIS.md', 'lib/module_registry/AI_ANALYSIS.md', 'lib/shared/AI_ANALYSIS.md', 'lib/modules/AI_ANALYSIS.md'], - validation: ['dart format .', 'flutter analyze', 'dart run flutterguard_cli:flutterguard scan --path . --fail-on high'], + depends: [ + 'packages/gcode_core', + 'packages/flutter_study_learning', + 'packages/file_picker_bridge', + 'packages/flutter_ioc_core', + `git:${flutterGuardDependency.url}#${flutterGuardDependency.ref}`, + ], + children: [ + 'lib/AI_ANALYSIS.md', + 'lib/app/AI_ANALYSIS.md', + 'lib/module_registry/AI_ANALYSIS.md', + 'lib/shared/AI_ANALYSIS.md', + 'lib/modules/AI_ANALYSIS.md', + ...workspacePackages.map(({ path: packagePath }) => `${packagePath}/AI_ANALYSIS.md`), + ], + validation: ['bash tool/generate_harness_ai_analysis.sh', 'dart format .', 'flutter analyze', 'dart run flutterguard_cli:flutterguard scan . --fail-on high'], }); writeIndex({ rel: 'lib/AI_ANALYSIS.md', id: 'main_app.lib', kind: 'source_index', - entrypoints: ['main.dart', 'app/app.dart', 'app/router/app_route_table.dart'], + entrypoints: ['main.dart', 'app/app_bootstrap.dart', 'app/app.dart', 'app/router/app_route_table.dart'], owns: ['app', 'module_registry', 'shared', 'modules'], depends: ['flutter_sdk', 'go_router', 'flutter_riverpod'], children: ['app/AI_ANALYSIS.md', 'module_registry/AI_ANALYSIS.md', 'shared/AI_ANALYSIS.md', 'modules/AI_ANALYSIS.md'], @@ -154,9 +415,9 @@ function writeLayerIndexes() { rel: 'lib/app/AI_ANALYSIS.md', id: 'main_app.app', kind: 'app_index', - entrypoints: ['app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], - owns: ['material_app_router', 'router'], - depends: ['go_router', 'module_registry', 'modules'], + entrypoints: ['app.dart', 'app_bootstrap.dart', 'module_home_page.dart', 'category_navigation.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], + owns: ['host_bootstrap', 'material_app_router', 'router', 'module_home', 'adaptive_category_navigation', 'desktop_category_window_shell'], + depends: ['go_router', 'module_registry', 'shared/multi_window', 'modules'], children: ['router/AI_ANALYSIS.md'], }); writeIndex({ @@ -164,33 +425,33 @@ function writeLayerIndexes() { id: 'main_app.app.router', kind: 'router_index', entrypoints: ['app_router.dart', 'app_route_table.dart'], - owns: ['go_router_root', 'module_route_aggregation', 'module_home_index'], - depends: ['module_registry', 'modules'], + owns: ['go_router_root', 'module_route_aggregation', 'module_catalog_composition'], + depends: ['app/module_home_page', 'module_registry', 'modules'], }); writeIndex({ rel: 'lib/module_registry/AI_ANALYSIS.md', id: 'main_app.module_registry', kind: 'registry_index', - entrypoints: ['module_entry.dart', 'module_category.dart'], - owns: ['module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum'], - depends: ['flutter_material'], + entrypoints: ['module_entry.dart', 'module_category.dart', 'module_catalog_utils.dart'], + owns: ['module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum', 'module_catalog_filtering', 'category_route_rebasing'], + depends: ['flutter_material', 'go_router'], }); writeIndex({ rel: 'lib/shared/AI_ANALYSIS.md', id: 'main_app.shared', kind: 'shared_index', entrypoints: ['multi_window', 'platform'], - owns: ['business_free_capabilities', 'desktop_windowing', 'platform_boundaries'], - depends: ['desktop_multi_window', '../file_picker_bridge'], + owns: ['business_free_capabilities', 'desktop_window_lifecycle', 'platform_boundaries'], + depends: ['desktop_multi_window', 'packages/file_picker_bridge'], children: ['multi_window/AI_ANALYSIS.md', 'platform/AI_ANALYSIS.md'], }); writeIndex({ rel: 'lib/shared/multi_window/AI_ANALYSIS.md', id: 'main_app.shared.multi_window', kind: 'shared_capability_index', - entrypoints: ['multi_window_manager.dart', 'category_window_app.dart', 'multi_window_route_filter.dart'], - owns: ['desktop_window_lifecycle', 'category_window_router', 'module_route_filter'], - depends: ['desktop_multi_window', 'go_router', 'module_registry'], + entrypoints: ['multi_window_manager.dart'], + owns: ['desktop_window_lifecycle', 'desktop_window_arguments'], + depends: ['desktop_multi_window', 'module_registry'], }); writeIndex({ rel: 'lib/shared/platform/AI_ANALYSIS.md', @@ -199,7 +460,7 @@ function writeLayerIndexes() { status: 'transition', entrypoints: ['AI_ANALYSIS.md'], owns: ['platform_boundary', 'host_channel_registry'], - depends: ['../file_picker_bridge', 'macos/Runner/AppDelegate.swift'], + depends: ['packages/file_picker_bridge', 'macos/Runner/AppDelegate.swift'], validation: ['flutter analyze', 'flutter build macos'], }); } @@ -260,10 +521,44 @@ function writeModuleContracts() { } } +function writePackageContracts() { + for (const packageMeta of workspacePackages) { + writeJson(`${packageMeta.path}/AI_ANALYSIS.md`, { + schema: 'vibecoding.harness.ai_analysis.v2', + mode: 'package_contract', + node: { + id: `flutter_study.workspace.${packageMeta.name}`, + kind: packageMeta.kind, + package: packageMeta.name, + path: packageMeta.path, + status: 'active', + }, + package_type: packageMeta.kind, + workspace: { + member: true, + resolution: 'workspace', + resolution_status: 'active', + resolution_blocker: 'none', + }, + entrypoints: packageMeta.entrypoints, + owns: packageMeta.owns, + depends: packageMeta.depends, + children: [], + contracts, + validation: packageMeta.validation, + test_status: packageMeta.test_status, + }); + } +} + writeSchema(); +writeProjectContext(); +writeRefactorPlan(); +writeModuleIndex(); writeRootIndexes(); writeLayerIndexes(); writeModuleIndexes(); writeModuleContracts(); +writePackageContracts(); console.log('agent index AI_ANALYSIS generation completed'); diff --git a/tool/generate_harness_ai_analysis.sh b/tool/generate_harness_ai_analysis.sh index f4950f9..82c57b7 100755 --- a/tool/generate_harness_ai_analysis.sh +++ b/tool/generate_harness_ai_analysis.sh @@ -3,3 +3,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" node "$ROOT/tool/generate_agent_indexes.js" +node "$ROOT/tool/validate_agent_docs.js" diff --git a/tool/migrate_sibling_packages.sh b/tool/migrate_sibling_packages.sh deleted file mode 100755 index 7b7c79a..0000000 --- a/tool/migrate_sibling_packages.sh +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PARENT="$(cd "$ROOT/.." && pwd)" -FORCE="${1:-}" - -PACKAGES=( - "gcode_core" - "flutter_study_learning" - "file_picker_bridge" - "flutter_ioc_core" -) - -if [[ ! -d "$ROOT/lib/modules/ui/gcode_visualizer/application" ]]; then - echo "Migration source directories are not present. The sibling package migration appears to have already been applied." >&2 - echo "Refusing to re-run to avoid overwriting the migrated sibling packages from incomplete in-app sources." >&2 - exit 1 -fi - -if [[ "$FORCE" != "--force" ]]; then - for package in "${PACKAGES[@]}"; do - if [[ -e "$PARENT/$package" ]]; then - echo "Refusing to overwrite $PARENT/$package. Re-run with --force." >&2 - exit 1 - fi - done -fi - -for package in "${PACKAGES[@]}"; do - if [[ "$FORCE" == "--force" ]]; then - rm -rf "$PARENT/$package" - fi -done - -mkdir -p "$PARENT/gcode_core/lib/src" "$PARENT/gcode_core/test/application" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/application" "$PARENT/gcode_core/lib/src/" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/data" "$PARENT/gcode_core/lib/src/" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/domain" "$PARENT/gcode_core/lib/src/" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/models" "$PARENT/gcode_core/lib/src/" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/parser" "$PARENT/gcode_core/lib/src/" -cp -R "$ROOT/lib/modules/ui/gcode_visualizer/services" "$PARENT/gcode_core/lib/src/" - -cat > "$PARENT/gcode_core/pubspec.yaml" <<'EOF' -name: gcode_core -description: Pure Dart G-code parsing, line reading, and toolpath building core. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dev_dependencies: - test: ^1.25.0 - lints: ^4.0.0 -EOF - -cat > "$PARENT/gcode_core/lib/gcode_core.dart" <<'EOF' -library gcode_core; - -export 'src/application/gcode_readline_pipeline.dart'; -export 'src/data/readers/file_gcode_line_reader.dart'; -export 'src/data/readers/gcode_line_reader.dart'; -export 'src/data/readers/string_gcode_line_reader.dart'; -export 'src/domain/gcode_line_record.dart'; -export 'src/domain/gcode_load_snapshot.dart'; -export 'src/domain/gcode_load_stage.dart'; -export 'src/domain/parsed_gcode_line.dart'; -export 'src/models/gcode_command.dart'; -export 'src/models/machine_position.dart'; -export 'src/models/toolpath_segment.dart'; -export 'src/parser/gcode_parse_result.dart'; -export 'src/parser/gcode_parser.dart'; -export 'src/services/toolpath_builder.dart'; -EOF - -cat > "$PARENT/gcode_core/README.md" <<'EOF' -# gcode_core - -Pure Dart G-code core extracted from `flutter_study`. - -## Scope - -- Read G-code from strings or files line by line. -- Parse G0/G1 commands with X/Y/F parameters. -- Collect parse errors with line metadata. -- Build incremental or batch toolpath segments. - -This package contains no Flutter UI, animation, canvas drawing, or file picker code. - -## Test - -```bash -dart test -``` -EOF - -cat > "$PARENT/gcode_core/AI_ANALYSIS.md" <<'EOF' -# gcode_core 分析 - -> 纯 Dart G-code 解析与轨迹构建核心。 - -## 功能目标 - -把 G-code 文本/文件读取、单行解析、错误收集和刀路段构建从 Flutter UI 模块中拆出,供教学 UI、CLI 或其它可视化前端复用。 - -## 文件结构 - -``` -gcode_core/ -├── lib/ -│ ├── gcode_core.dart -│ └── src/ -│ ├── application/gcode_readline_pipeline.dart -│ ├── data/readers/ -│ ├── domain/ -│ ├── models/ -│ ├── parser/ -│ └── services/toolpath_builder.dart -└── test/ -``` - -## 边界 - -- 不依赖 Flutter。 -- 不打开系统文件选择器。 -- 不绘制 Canvas。 -- 不管理播放动画。 -EOF - -mkdir -p "$PARENT/flutter_study_learning/lib/src" -cp "$ROOT/lib/shared/learning/learning_scaffold.dart" "$PARENT/flutter_study_learning/lib/src/learning_scaffold.dart" -cat > "$PARENT/flutter_study_learning/pubspec.yaml" <<'EOF' -name: flutter_study_learning -description: Shared learning scaffold widgets for Flutter study modules. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^4.0.0 - -flutter: - uses-material-design: true -EOF -cat > "$PARENT/flutter_study_learning/lib/flutter_study_learning.dart" <<'EOF' -library flutter_study_learning; - -export 'src/learning_scaffold.dart'; -EOF -cat > "$PARENT/flutter_study_learning/README.md" <<'EOF' -# flutter_study_learning - -Shared teaching page widgets for Flutter study modules. - -## Scope - -- `LearningScaffold` -- Learning objectives, concept chips, code snippets, state logs, pitfalls, and exercise cards - -This package has no module-specific business logic. -EOF -cat > "$PARENT/flutter_study_learning/AI_ANALYSIS.md" <<'EOF' -# flutter_study_learning 分析 - -> Flutter 学习模块的共享教学页面组件包。 - -## 边界 - -只承载教学表达组件,不持有任何模块状态、解析器、网络请求或平台能力。 -EOF - -mkdir -p "$PARENT/file_picker_bridge/lib/src" "$PARENT/file_picker_bridge/test" -cp "$ROOT/lib/shared/platform/file_picker/file_picker_service.dart" "$PARENT/file_picker_bridge/lib/src/" -cp "$ROOT/lib/shared/platform/file_picker/method_channel_file_picker.dart" "$PARENT/file_picker_bridge/lib/src/" -cp "$ROOT/test/shared/platform/file_picker/method_channel_file_picker_test.dart" "$PARENT/file_picker_bridge/test/" -cat > "$PARENT/file_picker_bridge/pubspec.yaml" <<'EOF' -name: file_picker_bridge -description: MethodChannel file picker API used by Flutter study modules. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^4.0.0 - -flutter: - uses-material-design: true -EOF -cat > "$PARENT/file_picker_bridge/lib/file_picker_bridge.dart" <<'EOF' -library file_picker_bridge; - -export 'src/file_picker_service.dart'; -export 'src/method_channel_file_picker.dart'; -EOF -cat > "$PARENT/file_picker_bridge/README.md" <<'EOF' -# file_picker_bridge - -Business-neutral file picker API for Flutter study modules. - -Current host implementation is registered by `main_app` on macOS through the `file_picker_bridge/file_picker` MethodChannel. This package owns the Dart API and mock-friendly channel implementation; it can be upgraded into a full Flutter plugin when more platforms are needed. -EOF -cat > "$PARENT/file_picker_bridge/AI_ANALYSIS.md" <<'EOF' -# file_picker_bridge 分析 - -> 文件选择能力包,当前承载 Dart API 和 MethodChannel 客户端。 - -## 平台协议 - -- Channel: `file_picker_bridge/file_picker` -- Method: `pickFile` -- 返回: `null` 或 `{ path: String, name: String? }` - -## 迁移状态 - -macOS 原生实现仍在宿主应用 `macos/Runner/AppDelegate.swift` 中注册。后续若升级为完整 Flutter plugin,再迁移原生代码。 -EOF -perl -0pi -e "s#import 'package:main_app/shared/platform/file_picker/method_channel_file_picker.dart';#import 'package:file_picker_bridge/file_picker_bridge.dart';#g" \ - "$PARENT/file_picker_bridge/test/method_channel_file_picker_test.dart" - -mkdir -p "$PARENT/flutter_ioc_core/lib/src" -cp "$ROOT/lib/modules/state/flutter_ioc/ioc/container.dart" "$PARENT/flutter_ioc_core/lib/src/" -cp "$ROOT/lib/modules/state/flutter_ioc/ioc/types.dart" "$PARENT/flutter_ioc_core/lib/src/" -cat > "$PARENT/flutter_ioc_core/pubspec.yaml" <<'EOF' -name: flutter_ioc_core -description: Pure Dart IoC container core extracted from Flutter study. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dev_dependencies: - test: ^1.25.0 - lints: ^4.0.0 -EOF -cat > "$PARENT/flutter_ioc_core/lib/flutter_ioc_core.dart" <<'EOF' -library flutter_ioc_core; - -export 'src/container.dart'; -export 'src/types.dart'; -EOF -cat > "$PARENT/flutter_ioc_core/README.md" <<'EOF' -# flutter_ioc_core - -Pure Dart IoC container extracted from the Flutter IoC teaching module. -EOF -cat > "$PARENT/flutter_ioc_core/AI_ANALYSIS.md" <<'EOF' -# flutter_ioc_core 分析 - -> 教学用 IoC 容器核心,支持 singleton/transient/scoped 生命周期、条件注册、属性注入和作用域。 - -不依赖 Flutter。 -EOF - -perl -0pi -e "s/\n gcode_core:\n path: \.\.\/gcode_core\n//g; s/\n flutter_study_learning:\n path: \.\.\/flutter_study_learning\n//g; s/\n file_picker_bridge:\n path: \.\.\/file_picker_bridge\n//g; s/\n flutter_ioc_core:\n path: \.\.\/flutter_ioc_core\n//g" "$ROOT/pubspec.yaml" -perl -0pi -e "s/dependencies:\n/dependencies:\n gcode_core:\n path: ..\/gcode_core\n flutter_study_learning:\n path: ..\/flutter_study_learning\n file_picker_bridge:\n path: ..\/file_picker_bridge\n flutter_ioc_core:\n path: ..\/flutter_ioc_core\n/" "$ROOT/pubspec.yaml" - -perl -0pi -e "s#import '../../../../shared/learning/learning_scaffold.dart';#import 'package:flutter_study_learning/flutter_study_learning.dart';#g" \ - "$ROOT/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart" \ - "$ROOT/lib/modules/basic/tree_state/pages/basic_widgets_page.dart" -perl -0pi -e "s#import '../domain/gcode_load_stage.dart';#import 'package:gcode_core/gcode_core.dart';#g" \ - "$ROOT/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart" -perl -0pi -e "s#import '../../../../shared/platform/file_picker/file_picker_service.dart';\nimport '../../../../shared/platform/file_picker/method_channel_file_picker.dart';\nimport '../application/gcode_readline_pipeline.dart';\nimport '../data/readers/file_gcode_line_reader.dart';\nimport '../data/readers/gcode_line_reader.dart';\nimport '../data/readers/string_gcode_line_reader.dart';\nimport '../domain/gcode_load_stage.dart';\nimport '../models/toolpath_segment.dart';\nimport '../parser/gcode_parse_result.dart';#import 'package:file_picker_bridge/file_picker_bridge.dart';\nimport 'package:gcode_core/gcode_core.dart';#s" \ - "$ROOT/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart" -perl -0pi -e "s#import '../models/gcode_command.dart';\nimport '../models/toolpath_segment.dart';#import 'package:gcode_core/gcode_core.dart';#s" \ - "$ROOT/lib/modules/ui/gcode_visualizer/widgets/gcode_canvas.dart" -perl -0pi -e "s#import '../models/gcode_command.dart';\nimport '../parser/gcode_parse_result.dart';#import 'package:gcode_core/gcode_core.dart';#s" \ - "$ROOT/lib/modules/ui/gcode_visualizer/widgets/command_timeline.dart" -perl -0pi -e "s#import 'ioc/ioc.dart' as ioc;#import 'package:flutter_ioc_core/flutter_ioc_core.dart' as ioc;#g" \ - "$ROOT/lib/modules/state/flutter_ioc/module_entry.dart" - -perl -0pi -e "s#import 'package:main_app/shared/platform/file_picker/method_channel_file_picker.dart';#import 'package:file_picker_bridge/file_picker_bridge.dart';#g" \ - "$ROOT/test/shared/platform/file_picker/method_channel_file_picker_test.dart" - -rm -rf "$ROOT/lib/modules/ui/gcode_visualizer/application" \ - "$ROOT/lib/modules/ui/gcode_visualizer/data" \ - "$ROOT/lib/modules/ui/gcode_visualizer/domain" \ - "$ROOT/lib/modules/ui/gcode_visualizer/models" \ - "$ROOT/lib/modules/ui/gcode_visualizer/parser" \ - "$ROOT/lib/modules/ui/gcode_visualizer/services" \ - "$ROOT/lib/modules/state/flutter_ioc/ioc" \ - "$ROOT/lib/shared/learning" \ - "$ROOT/lib/shared/platform/file_picker" -rm -rf "$ROOT/test/shared/platform/file_picker" - -cat > "$ROOT/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md" <<'EOF' -# G-code Visualizer 模块分析 - -> G-code 解析与轨迹动画演示模块。纯解析与轨迹构建逻辑已迁移到同级包 `../gcode_core`,本模块只保留 Flutter 教学 UI、状态编排和绘制交互。 - -## 功能目标 - -调用 `gcode_core` 将 G-code 文本或文件解析为轨迹段,并通过 CustomPaint、AnimationController 和教学模板展示 G0/G1 刀路执行过程。 - -## 文件结构 - -``` -modules/ui/gcode_visualizer/ -├── module_entry.dart # 入口: 返回 GcodeVisualizerPage -├── AI_ANALYSIS.md # 模块分析文档 -├── pages/ -│ └── gcode_visualizer_page.dart # 教学页面,依赖 flutter_study_learning -├── state/ -│ └── gcode_player_controller.dart # ChangeNotifier + AnimationController,编排解析、文件选择和播放 -└── widgets/ - ├── command_timeline.dart # 指令列表,高亮当前执行行 - ├── gcode_canvas.dart # CustomPaint 轨迹画布 - ├── gcode_editor_panel.dart # 编辑器 + 文件路径输入/选择/提取按钮 - └── playback_controls.dart # 播放/暂停/重置/进度/速度控制 -``` - -## 外部包依赖 - -| 包 | 用途 | -|---|---| -| `gcode_core` | G-code 读取、解析、错误收集、轨迹构建 | -| `flutter_study_learning` | 教学页面模板组件 | -| `file_picker_bridge` | 文件选择 Dart API,macOS 原生通道仍由宿主应用注册 | - -## 数据流 - -``` -source text / path input / file picker - -> gcode_core GcodeLineReader - -> gcode_core GcodeReadlinePipeline - -> GcodePlayerController - -> GcodeCanvas / CommandTimeline / PlaybackControls -``` - -## 修改注意事项 - -1. 新增 G-code 语法、reader 或 toolpath 规则时修改 `../gcode_core`。 -2. 本模块只处理 Flutter UI、播放状态和教学表达。 -3. 文件选择能力来自 `file_picker_bridge`,模块只传入 G-code 扩展名和弹窗文案。 -4. 教学页面组件来自 `flutter_study_learning`。 -EOF - -cat > "$ROOT/lib/shared/AI_ANALYSIS.md" <<'EOF' -# Shared 层分析 - -> shared 层已完成第一轮外置迁移。教学模板、平台文件选择等可复用能力已迁移到项目同级 package,主应用通过 path dependency 引用。 - -## 当前定位 - -`lib/shared/` 只保留跨模块共享能力的项目内文档和后续过渡能力。新增稳定能力时优先评估是否直接进入同级 package。 - -## 已迁移能力 - -| 能力 | 同级包 | 当前使用方 | -|---|---|---| -| 教学模板 | `../flutter_study_learning` | tree_state, gcode_visualizer | -| 文件选择 Dart API | `../file_picker_bridge` | gcode_visualizer | - -## 维护规则 - -1. `shared/` 不得 import `modules/`。 -2. 新增 shared 代码前优先评估是否应放入同级 package。 -3. 业务模块只能依赖 package API,不直接依赖平台通道细节。 -EOF - -cat > "$ROOT/lib/shared/platform/AI_ANALYSIS.md" <<'EOF' -# Platform 共享层分析 - -> 平台能力正在从主应用 shared 层迁移到同级 package。 - -## 当前能力 - -| 能力 | 包 | 宿主原生实现 | -|---|---|---| -| 文件选择 | `../file_picker_bridge` | macOS `AppDelegate.swift` 注册 `file_picker_bridge/file_picker` | - -## 后续计划 - -当需要 Windows/iOS/Android 文件选择实现时,将 `file_picker_bridge` 从 Dart API package 升级为完整 Flutter plugin,并迁移 macOS 原生实现。 -EOF - -cat > "$ROOT/lib/modules/state/flutter_ioc/AI_ANALYSIS.md" <<'EOF' -# AI 模块分析: flutter_ioc - -> 自研 IoC 容器教学模块。IoC 核心逻辑已迁移到同级纯 Dart 包 `../flutter_ioc_core`,本模块保留 Provider 接入和计数器教学 UI。 - -## 文件结构 - -``` -modules/state/flutter_ioc/ -├── module_entry.dart # 创建 flutter_ioc_core.Container 并注入 Provider -├── module_root.dart # CounterScreen 教学 UI -├── model/counter_model.dart -└── AI_ANALYSIS.md -``` - -## 外部包依赖 - -| 包 | 用途 | -|---|---| -| `flutter_ioc_core` | Container、IoCContainer、生命周期、条件注册、属性注入 | - -## 修改注意事项 - -1. IoC 容器能力变更应修改 `../flutter_ioc_core`。 -2. 本模块只维护 Flutter/Provider 教学集成。 -EOF - -perl -0pi -e 's#lib/shared/learning/learning_scaffold.dart#package:flutter_study_learning#g; s#`lib/shared/platform/file_picker/`#`../file_picker_bridge`#g; s#`shared/platform/file_picker/`#`../file_picker_bridge`#g' \ - "$ROOT/README.md" "$ROOT/AI_PROJECT_CONTEXT.md" "$ROOT/REFACTOR_PLAN.md" "$ROOT/PLUGIN_DECOMPOSITION_PLAN.md" "$ROOT/lib/modules/basic/tree_state/AI_ANALYSIS.md" - -echo "Sibling package migration completed." diff --git a/tool/quality_gate.sh b/tool/quality_gate.sh new file mode 100755 index 0000000..fb097cc --- /dev/null +++ b/tool/quality_gate.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# quality_gate.sh — 全量质量门禁 +# 按固定顺序执行: 文档生成/校验 → 格式 → 分析 → 测试 → FlutterGuard +# 任一步失败返回非零状态码并指明失败阶段。 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +PASS_COUNT=0 +FAIL_COUNT=0 + +run_stage() { + local label="$1" + shift + echo "--- [$label] ---" + if "$@" 2>&1; then + echo -e " ${GREEN}✓ $label 通过${NC}" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo -e " ${RED}✗ $label 失败${NC}" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + echo "" +} + +echo "=== Flutter Study 质量门禁 ===" +echo "" + +# Stage 1: Agent 文档生成 + 校验 + 漂移检测 +run_stage "Agent 文档生成与校验" bash -c " + bash tool/generate_harness_ai_analysis.sh && + git diff --exit-code -- AI_ANALYSIS_SCHEMA.json AI_PROJECT_CONTEXT.md REFACTOR_PLAN.md lib/**/AI_ANALYSIS.md lib/AI_MODULE_INDEX.md packages/**/AI_ANALYSIS.md +" + +# Stage 2: 代码格式 +run_stage "Dart 格式" bash -c " + dart format . && + git diff --exit-code -- '*.dart' +" + +# Stage 3: 静态分析 +run_stage "Flutter 静态分析" flutter analyze --no-fatal-infos --no-fatal-warnings + +# Stage 4: 测试 +run_stage "全量测试" bash tool/test_all.sh + +# Stage 5: FlutterGuard 安全扫描 +run_stage "FlutterGuard" dart run flutterguard_cli:flutterguard scan . --fail-on high + +echo "=== 质量门禁完成 ===" +echo -e " 通过: ${GREEN}$PASS_COUNT${NC}" +echo -e " 失败: ${RED}$FAIL_COUNT${NC}" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi +echo "所有门禁通过。" diff --git a/tool/test_agent_tools.sh b/tool/test_agent_tools.sh new file mode 100755 index 0000000..de59a4f --- /dev/null +++ b/tool/test_agent_tools.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test_agent_tools.sh — generator + validator 冒烟/回归测试 +# 依赖: Node.js, bash, git + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PASS=0 +FAIL=0 + +run_test() { + local label="$1" + shift + echo -n " [$label] ... " + if "$@" >/dev/null 2>&1; then + echo "PASS" + PASS=$((PASS + 1)) + else + echo "FAIL" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== Agent Tool 回归测试 ===" +echo "" + +echo "--- Generator ---" +run_test "node syntax" node --check tool/generate_agent_indexes.js +run_test "generator runs" bash tool/generate_harness_ai_analysis.sh +run_test "generator deterministic (2nd run)" bash -c 'bash tool/generate_harness_ai_analysis.sh >/dev/null 2>&1' +run_test "AI_PROJECT_CONTEXT valid JSON" node -e "JSON.parse(require('fs').readFileSync('AI_PROJECT_CONTEXT.md','utf8'))" +run_test "REFACTOR_PLAN valid JSON" node -e "JSON.parse(require('fs').readFileSync('REFACTOR_PLAN.md','utf8'))" +run_test "AI_MODULE_INDEX valid JSON" node -e "JSON.parse(require('fs').readFileSync('lib/AI_MODULE_INDEX.md','utf8'))" +run_test "AI_ANALYSIS_SCHEMA valid JSON" node -e "JSON.parse(require('fs').readFileSync('AI_ANALYSIS_SCHEMA.json','utf8'))" + +echo "" +echo "--- Validator ---" +run_test "node syntax" node --check tool/validate_agent_docs.js +run_test "validator passes" node tool/validate_agent_docs.js +run_test "validator detects JSON error" bash -c ' + echo "{" > tool/.tmp_bad_schema.json + sed "s|AI_ANALYSIS_SCHEMA.json|tool/.tmp_bad_schema.json|g" tool/validate_agent_docs.js > tool/.test-validator.js + node tool/.test-validator.js 2>&1 | grep -q "invalid_json" || exit 1 + rm -f tool/.test-validator.js tool/.tmp_bad_schema.json +' +run_test "validator detects unregistered module" bash -c ' + mkdir -p lib/modules/basic/__unregistered_test_xyz__ + touch lib/modules/basic/__unregistered_test_xyz__/module_entry.dart + node tool/validate_agent_docs.js 2>&1 | grep -q "unregistered_module" || exit 1 + rm -rf lib/modules/basic/__unregistered_test_xyz__ +' + +echo "" +echo "--- Workspace Packages ---" +for pkg in gcode_core flutter_study_learning file_picker_bridge flutter_ioc_core; do + run_test "${pkg} contract valid" node -e "JSON.parse(require('fs').readFileSync('packages/${pkg}/AI_ANALYSIS.md','utf8'))" + run_test "${pkg} manifest has workspace resolution" grep -q 'resolution: workspace' "packages/${pkg}/pubspec.yaml" +done + +echo "" +echo "--- Summary ---" +echo " PASS: $PASS FAIL: $FAIL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +echo "All tests passed." diff --git a/tool/test_all.sh b/tool/test_all.sh new file mode 100755 index 0000000..0212937 --- /dev/null +++ b/tool/test_all.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test_all.sh — 遍历主应用与 workspace packages 执行测试 +# 退出码: 0 = 全部通过, 非0 = 有失败 + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +TOTAL=0 +PASSED=0 +FAILED=0 +SKIPPED=0 + +run_tests() { + local dir="$1" + local label="$2" + local cmd="$3" + + TOTAL=$((TOTAL + 1)) + + if [ ! -d "$dir/test" ]; then + echo " ⚠ $label: 无 test/ 目录,跳过" + SKIPPED=$((SKIPPED + 1)) + return 0 + fi + + echo " ▶ $label ($cmd)" + if (cd "$dir" && eval "$cmd" 2>&1) ; then + echo " ✓ 通过" + PASSED=$((PASSED + 1)) + else + echo " ✗ 失败" + FAILED=$((FAILED + 1)) + fi + echo "" +} + +echo "=== Flutter Study 全量测试 ===" +echo "" + +# 主应用 +run_tests "." "main_app" "flutter test" + +# Workspace packages +run_tests "packages/gcode_core" "gcode_core" "flutter test" +run_tests "packages/flutter_study_learning" "flutter_study_learning" "flutter test" +run_tests "packages/file_picker_bridge" "file_picker_bridge" "flutter test" +run_tests "packages/flutter_ioc_core" "flutter_ioc_core" "flutter test" + +echo "--- 汇总 ---" +echo " 总计: $TOTAL | 通过: $PASSED | 失败: $FAILED | 跳过: $SKIPPED" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi +echo "全部通过。" diff --git a/tool/validate_agent_docs.js b/tool/validate_agent_docs.js new file mode 100644 index 0000000..35abc00 --- /dev/null +++ b/tool/validate_agent_docs.js @@ -0,0 +1,293 @@ +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const failures = []; +const documents = new Map(); + +const VALID_CATEGORIES = ['basic', 'async', 'state', 'ui', 'popup_table', 'platform']; +const workspacePackages = [ + ['gcode_core', 'packages/gcode_core'], + ['flutter_study_learning', 'packages/flutter_study_learning'], + ['file_picker_bridge', 'packages/file_picker_bridge'], + ['flutter_ioc_core', 'packages/flutter_ioc_core'], +]; + +// ── helpers ────────────────────────────────────────────────────── + +function readJson(rel) { + try { + const source = fs.readFileSync(path.join(root, rel), 'utf8'); + if (/[^\x00-\x7F]/.test(source)) failures.push(`${rel}:non_ascii_content`); + const document = JSON.parse(source); + documents.set(rel, document); + return document; + } catch (error) { + failures.push(`${rel}:invalid_json:${error.message}`); + return null; + } +} + +function collectAnalysisFiles(dir) { + const result = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'build') continue; + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + result.push(...collectAnalysisFiles(absolute)); + } else if (entry.name === 'AI_ANALYSIS.md') { + result.push(path.relative(root, absolute)); + } + } + return result; +} + +function fileExists(rel) { + return fs.existsSync(path.join(root, rel)); +} + +function grepInFile(rel, pattern) { + if (!fileExists(rel)) return false; + const content = fs.readFileSync(path.join(root, rel), 'utf8'); + return pattern.test(content); +} + +function scanModuleDirs() { + const dirs = []; + const modulesRoot = path.join(root, 'lib/modules'); + if (!fs.existsSync(modulesRoot)) return dirs; + for (const cat of fs.readdirSync(modulesRoot, { withFileTypes: true })) { + if (!cat.isDirectory() || cat.name.startsWith('.')) continue; + for (const mod of fs.readdirSync(path.join(modulesRoot, cat.name), { withFileTypes: true })) { + if (!mod.isDirectory() || mod.name.startsWith('.')) continue; + const relPath = `lib/modules/${cat.name}/${mod.name}`; + dirs.push({ category: cat.name, module: mod.name, path: relPath }); + } + } + return dirs; +} + +// ── collect all machine documents ───────────────────────────────── + +const machineDocuments = [ + 'AI_ANALYSIS_SCHEMA.json', + 'AI_PROJECT_CONTEXT.md', + 'REFACTOR_PLAN.md', + 'lib/AI_MODULE_INDEX.md', + ...collectAnalysisFiles(root), +]; + +// ── phase 1: JSON parse + key validation ────────────────────────── + +const requiredAnalysisKeys = [ + 'schema', 'mode', 'node', 'entrypoints', 'owns', + 'depends', 'children', 'contracts', 'validation', +]; + +for (const rel of [...new Set(machineDocuments)].sort()) { + const document = readJson(rel); + if (!document) continue; + const basename = path.basename(rel); + if (basename !== 'AI_ANALYSIS.md') continue; + + for (const key of requiredAnalysisKeys) { + if (!(key in document)) failures.push(`${rel}:missing_key:${key}`); + } + if (document.contracts?.no_natural_language !== true) { + failures.push(`${rel}:contract:no_natural_language`); + } + if (document.contracts?.doc_consumer !== 'coding_agent') { + failures.push(`${rel}:contract:doc_consumer`); + } + if (document.contracts?.doc_mode !== 'machine_contract') { + failures.push(`${rel}:contract:doc_mode`); + } + + for (const child of document.children ?? []) { + const childPath = path.normalize(path.join(path.dirname(rel), child)); + if (!fs.existsSync(path.join(root, childPath))) { + failures.push(`${rel}:missing_child:${child}`); + } + } +} + +// ── phase 2: module index validation ────────────────────────────── + +const moduleIndex = documents.get('lib/AI_MODULE_INDEX.md'); +if (moduleIndex) { + if (moduleIndex.count !== moduleIndex.modules?.length) { + failures.push('lib/AI_MODULE_INDEX.md:count_mismatch'); + } + const ids = new Set(); + const routes = new Set(); + const indexedModules = new Map(); + + for (const mod of moduleIndex.modules ?? []) { + indexedModules.set(mod.id, mod); + + if (ids.has(mod.id)) failures.push(`lib/AI_MODULE_INDEX.md:duplicate_id:${mod.id}`); + if (routes.has(mod.route)) failures.push(`lib/AI_MODULE_INDEX.md:duplicate_route:${mod.route}`); + ids.add(mod.id); + routes.add(mod.route); + + const contract = documents.get(mod.analysis); + if (!contract) { + failures.push(`lib/AI_MODULE_INDEX.md:missing_analysis:${mod.analysis}`); + continue; + } + for (const key of ['route', 'category']) { + if (contract[key] !== mod[key]) { + failures.push(`${mod.analysis}:index_mismatch:${key}`); + } + } + if (contract.node?.status !== mod.status) { + failures.push(`${mod.analysis}:index_mismatch:status`); + } + } + + // ── phase 3: directory ↔ index cross-check ────────────────────── + + const dirModules = scanModuleDirs(); + const dirModuleIds = new Set(dirModules.map(d => d.module)); + const dirModulePaths = new Map(dirModules.map(d => [d.module, d.path])); + + // Every directory must be in the index + for (const dirMod of dirModules) { + if (!VALID_CATEGORIES.includes(dirMod.category)) { + failures.push(`${dirMod.path}:invalid_category:${dirMod.category}`); + } + if (!ids.has(dirMod.module)) { + failures.push(`${dirMod.path}:unregistered_module — not found in lib/AI_MODULE_INDEX.md`); + } + } + + // Every index entry must have a matching directory + for (const mod of moduleIndex.modules ?? []) { + if (!dirModuleIds.has(mod.id)) { + failures.push(`${mod.analysis}:orphan_index_entry — no directory under lib/modules/`); + continue; + } + const expectedPath = dirModulePaths.get(mod.id); + if (mod.path !== expectedPath) { + failures.push(`${mod.analysis}:path_mismatch:index=${mod.path} fs=${expectedPath}`); + } + } + + // ── phase 4: naming conventions ───────────────────────────────── + + const SNAKE_CASE = /^[a-z][a-z0-9_]*$/; + const KEBAB_ROUTE = /^\/[a-z][a-z0-9-]*$/; + + for (const dirMod of dirModules) { + if (!SNAKE_CASE.test(dirMod.module)) { + failures.push(`${dirMod.path}:naming_convention:dir must be snake_case, got "${dirMod.module}"`); + } + } + + for (const mod of moduleIndex.modules ?? []) { + if (!KEBAB_ROUTE.test(mod.route)) { + failures.push(`${mod.analysis}:naming_convention:route must be kebab-case starting with /, got "${mod.route}"`); + } + } + + // ── phase 5: module_entry.dart + teaching template check ──────── + + for (const mod of moduleIndex.modules ?? []) { + const entryFile = `${mod.path}/module_entry.dart`; + if (!fileExists(entryFile)) { + failures.push(`${mod.path}:missing_module_entry`); + } + + // Check at least one .dart file in the module imports flutter_study_learning + const modDir = path.join(root, mod.path); + if (fs.existsSync(modDir)) { + let hasTeachingDep = false; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'AI_ANALYSIS.md') continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(abs); + } else if (entry.name.endsWith('.dart')) { + const content = fs.readFileSync(abs, 'utf8'); + if (/flutter_study_learning/.test(content)) { + hasTeachingDep = true; + } + } + } + } + walk(modDir); + if (!hasTeachingDep) { + failures.push(`${mod.path}:missing_teaching_dependency — no file imports flutter_study_learning`); + } + } + } +} + +// ── phase 6: workspace package contract validation ───────────────── + +for (const [packageName, packagePath] of workspacePackages) { + const analysisPath = `${packagePath}/AI_ANALYSIS.md`; + const manifestPath = `${packagePath}/pubspec.yaml`; + const contract = documents.get(analysisPath); + if (!contract) { + failures.push(`${analysisPath}:missing_package_contract`); + continue; + } + if (contract.mode !== 'package_contract') { + failures.push(`${analysisPath}:mode:package_contract`); + } + if (contract.node?.package !== packageName) { + failures.push(`${analysisPath}:package_name_mismatch`); + } + if (contract.node?.path !== packagePath) { + failures.push(`${analysisPath}:package_path_mismatch`); + } + + if (fileExists(manifestPath)) { + const manifest = fs.readFileSync(path.join(root, manifestPath), 'utf8'); + if (!new RegExp(`^name:\\s*${packageName}$`, 'm').test(manifest)) { + failures.push(`${manifestPath}:name_mismatch`); + } + if (!/^resolution:\s*workspace$/m.test(manifest)) { + failures.push(`${manifestPath}:missing_workspace_resolution`); + } + } +} + +// ── phase 7: schema document validation ─────────────────────────── + +const schema = documents.get('AI_ANALYSIS_SCHEMA.json'); +if (schema) { + // Verify schema declares all known analysis files + const declared = new Set(); + for (const level of Object.values(schema.levels ?? {})) { + for (const f of level) { + // Normalize glob patterns to check if they match + if (f.includes('*')) { + // Glob pattern like "lib/modules/*/*/AI_ANALYSIS.md" + // Count how many actual files match + let count = 0; + for (const doc of documents.keys()) { + if (doc.startsWith('lib/modules/') && path.basename(doc) === 'AI_ANALYSIS.md') { + // Only match module-level (2 levels deep under modules/) + const parts = doc.split('/'); + if (parts.length === 5) count++; // lib/modules/cat/mod/AI_ANALYSIS.md + } + } + if (count > 0) declared.add(f); + } else { + declared.add(f); + } + } + } +} + +// ── report ──────────────────────────────────────────────────────── + +if (failures.length > 0) { + process.stderr.write(`${failures.join('\n')}\n`); + process.exit(1); +} + +process.stdout.write(`agent_docs_valid:${new Set(machineDocuments).size}\n`);