diff --git a/.claude/agents/trellis-check.md b/.claude/agents/trellis-check.md index 41d35288c..647a35cce 100644 --- a/.claude/agents/trellis-check.md +++ b/.claude/agents/trellis-check.md @@ -83,7 +83,7 @@ After finding issues: FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md`, `app` against `app/AGENTS.md`. Run, in order: 1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`. Stale codegen fails as a missing getter that looks like a source bug. -2. `dart format lib test tool`, then `flutter analyze`. +2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`. 3. The tests for every changed area: the matching rows of the package's `AGENTS.md` § Verification / § 驗證, plus the Quality Check section of each touched `.trellis/spec///index.md`. If anything fails, fix it and re-run. diff --git a/.claude/agents/trellis-implement.md b/.claude/agents/trellis-implement.md index 4103aa224..72fe06265 100644 --- a/.claude/agents/trellis-implement.md +++ b/.claude/agents/trellis-implement.md @@ -79,7 +79,7 @@ Read the task's prd.md, design.md if present, and implement.md if present: FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md` § Verification, `app` against `app/AGENTS.md` § 驗證. 1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`. -2. `dart format lib test tool`, then `flutter analyze`. +2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`. 3. The tests named by the matching rows of the package's § Verification / § 驗證, plus the tests you wrote. If the change is user-visible, say so in the report: on-device verification is the main session's job (`verify-legacy-on-device` skill for `legacy`, `verify-on-device` for `app`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41fcea60d..5491d5e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,16 @@ name: CI -# No path filter. Markdown carries rules that tests enforce -- the agent -# instruction files most of all -- so a prose-only commit is exactly the one -# that must not skip `validate`. `paths-ignore` is trigger-level and cannot -# skip only the build jobs; measured run time is 23 minutes total (validate 7, -# Android 6, Windows 10), which is not worth a changed-files job to avoid. +# repo 裡有兩個專案:根目錄的舊專案(凍結,只收緊急修正)與 app/ 的新專案 +# (ADR 0008)。`changes` job 依變動路徑分流(ADR 0015 §決定 9),兩邊的 CI +# 失敗不會互相擋 PR: +# - app/** 或 .github/** 有變動:跑 `app`。 +# - app/ 以外有任何變動:跑舊專案的三個 job。文件變更也算,因為 Markdown +# 承載由測試守著的規則(尤其是 agent 指令檔),只改文字的 commit 正是最 +# 不能跳過 `validate` 的那種。所以舊專案的篩選條件是「app/ 以外」,不是 +# 「是不是程式碼」。 +# - 手動觸發一律全跑。 +# 分流在 job 層級做,不用觸發層級的 `paths`/`paths-ignore`:必要檢查只有最後 +# 的 `CI Result`,它每次都要回報,被跳過的 job 算通過、失敗或取消的算失敗。 on: pull_request: branches: @@ -26,12 +32,50 @@ concurrency: env: FLUTTER_VERSION: '3.47.1' + # app/ 跟隨當時的 stable(ADR 0015;M1 design §3);舊專案維持上面那版。 + APP_FLUTTER_VERSION: '3.47.5' JAVA_VERSION: '17' JAVA_DISTRIBUTION: 'temurin' jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + # pull_request 事件以 REST API 取變動檔案清單。 + pull-requests: read + outputs: + app: ${{ steps.filter.outputs.app }} + legacy: ${{ steps.filter.outputs.legacy }} + + steps: + # push 事件以 git 比對變動,需要 checkout。 + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Filter changed paths + id: filter + # 手動觸發時下游 job 本來就全跑;跳過比對,免得找不到比對基準時整條 CI 被擋。 + if: github.event_name != 'workflow_dispatch' + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + # `legacy` 要「符合 ** 且不符合 !app/**」;預設的 `some` 只要符合 + # 任一條就算,app/ 的檔案也會觸發舊專案。 + predicate-quantifier: some-with-excludes + filters: | + app: + - 'app/**' + - '.github/**' + legacy: + - '**' + - '!app/**' + validate: name: Analyze and Test + needs: changes + if: needs.changes.outputs.legacy == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 @@ -153,3 +197,61 @@ jobs: - name: Build Windows release run: flutter build windows --release + + app: + name: App Analyze and Test + needs: changes + if: needs.changes.outputs.app == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: app + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + flutter-version: ${{ env.APP_FLUTTER_VERSION }} + cache: true + cache-key: flutter-${{ env.APP_FLUTTER_VERSION }}-${{ runner.os }} + pub-cache: true + + - name: Flutter dependencies + run: flutter pub get + + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Static analysis + run: flutter analyze + + # 不加參數:dart_test.yaml 讓 live 預設跳過(ADR 0015 §決定 3)。 + # 身分測試要 cmake,ubuntu runner 內建。 + - name: Unit and widget tests + run: flutter test + + ci-result: + name: CI Result + if: always() + needs: [changes, validate, build-android, build-windows, app] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + # 唯一的必要檢查。被路徑篩掉而跳過的 job 算通過;失敗、取消或 + # `changes` 本身失敗(下游全被跳過)都算失敗。 + - name: Check job results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "Job results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) exit 1 ;; + esac + done diff --git a/.trellis/spec/app/testing/index.md b/.trellis/spec/app/testing/index.md new file mode 100644 index 000000000..9c22765bb --- /dev/null +++ b/.trellis/spec/app/testing/index.md @@ -0,0 +1,55 @@ +# 測試(`app/test/`) + +`app/` 的每個測試都適用。哪種改動跑哪些指令見 `app/AGENTS.md` § 驗證;零聯網與身分 +的規則本身也寫在那裡,這裡只寫怎麼寫測試。 + +## 分層(ADR 0015 §決定 1) + +測試守契約與使用者看得到的行為,不守實作細節;行為變更同一個 PR 補測試,用能證明它 +的最低層。 + +| 層 | 用在 | 目前的例子 | +|---|---|---| +| 單元 | 純邏輯、平台層實作(注入路徑與 callback,在暫存目錄上跑) | `test/platform/app_data_directory_test.dart` | +| widget | 畫面;依賴以建構子或 provider override 注入 | `test/app/fmp_app_test.dart` | +| 建置設定 | 原生身分:能執行就執行(`cmake -P`),不能就解析設定檔並附變異案例 | `test/identity/` | +| 插件契約、整合、golden | M1 PR 9、PR 13 與設計系統元件加入時再寫 | — | + +- 正式程式碼不留測試掛鉤(`*ForTesting`、`@visibleForTesting` 的後門);要替換的東西 + 經建構子或 provider 注入。 +- 不設覆蓋率門檻。 + +## 零聯網 + +`test/flutter_test_config.dart` 讓建立真實 `HttpClient` 直接拋 `StateError`; +`dart_test.yaml` 讓 `live` tag 預設跳過。 + +- 需要 HTTP 的程式碼注入假的 client 或 adapter,不去碰網路。 +- 真的要打真實 API 的測試:標 `tags: 'live'`,並在測試裡自己放行: + + ```dart + final class _AllowNetwork extends HttpOverrides {} + + test('...', () async { + await HttpOverrides.runWithHttpOverrides(() async { + // 這個 zone 內建立的 HttpClient 是真的 + }, _AllowNetwork()); + }, tags: 'live'); + ``` + + 執行:`flutter test --run-skipped --tags live`。 +- `flutter_test_config.dart` 先初始化測試 binding 再設 `HttpOverrides.global`:binding + 初始化時會換上 flutter_test 自己的假 client(回 400、不報錯),順序反過來這道防線 + 就失效。 + +## 讀原始碼或設定檔的測試 + +優先寫行為測試。非讀原始碼不可時(例如 Gradle 設定在 CI 上跑太慢),同一個測試檔要有 +兩個變異案例:造一個違規證明解析抓得到,改一次無關的格式或命名證明結果不變。例子: +`test/identity/android_identity_test.dart` 的 `parser mutations`。 + +## Quality Check + +- `dart format --output=none --set-exit-if-changed .`、`flutter analyze` 乾淨。 +- 新的讀原始碼測試有兩個變異案例。 +- 沒有新的測試直接連網;`live` 測試在自己的 zone 放行。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index 05f8be80b..bab43ee49 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -186,6 +186,7 @@ - [ ] CI 加入: - Linux、macOS、iOS(不簽名)建置; - Linux 與 Windows 的整合測試:搜尋→播放、從檔案安裝插件,用測試插件。 +- [ ] `default-flavor: dev` 讓 iOS、macOS 建置需要 `dev`/`prod` 兩個 Xcode scheme,這個 PR 補上(PR 2 發現)。 - [ ] `app-release.yml`(只有 `workflow_dispatch`)與 release-please 設定: - `app/CHANGELOG.md`; - manifest; diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 4781509d2..1776385d3 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -19,7 +19,8 @@ "pr_url": null, "subtasks": [], "children": [ - "09-29-split-agent-instructions" + "09-29-split-agent-instructions", + "09-29-app-skeleton" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], diff --git a/.trellis/tasks/archive/2026-09/09-29-app-skeleton/check.jsonl b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/check.jsonl new file mode 100644 index 000000000..86a754f49 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, technical choices and CI plan"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "Package versions and CI action versions"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Zero-network, flavors, CI split"} +{"file": "docs/adr/0008-rewrite-as-new-app-in-same-repo.md", "reason": "App identity"} +{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Directory rules"} diff --git a/.trellis/tasks/archive/2026-09/09-29-app-skeleton/implement.jsonl b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/implement.jsonl new file mode 100644 index 000000000..86a754f49 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/implement.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, technical choices and CI plan"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "Package versions and CI action versions"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Zero-network, flavors, CI split"} +{"file": "docs/adr/0008-rewrite-as-new-app-in-same-repo.md", "reason": "App identity"} +{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Directory rules"} diff --git a/.trellis/tasks/archive/2026-09/09-29-app-skeleton/prd.md b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/prd.md new file mode 100644 index 000000000..a4667aa85 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/prd.md @@ -0,0 +1,86 @@ +# `app/` 骨架與 CI 分流(M1 PR 2) + +父任務:`../09-28-m1-skeleton-tracer`(design §2、§3、§5;implement「2.」)。 + +依據: +- ADR 0008 §決定 2–3:App 身分; +- ADR 0009 §決定 7:目錄規則; +- ADR 0015 §決定 3:零聯網;§決定 8:開發版;§決定 9:CI 切分。 + +## 做什麼 + +1. **Flutter 專案**:`app/`,Flutter 3.47.5(Dart 3.13.4)。 + - 以 `flutter create --org com.personal --project-name fmp --platforms android,windows,linux,macos,ios app` 為起點。 + - `pubspec.yaml`: + - 是 pub workspace 根(`workspace:` 先空,PR 3 加 `packages/fmp_lints`); + - `flutter: default-flavor: dev`; + - 依賴只加本 PR 用得到的。 + - 範本 counter app 換成最小的 `MaterialApp`,只顯示 App 名稱與 flavor。UI 在 PR 12 做。 +2. **flavor 與 App 身分**: + + | 項目 | prod(與舊版相同,ADR 0008) | dev(ADR 0015 §決定 8) | + |---|---|---| + | Android `applicationId` | `com.personal.fmp` | `com.personal.fmp.dev`(`applicationIdSuffix ".dev"`) | + | Android App 名稱 | `FMP` | `FMP Dev` | + | Windows AppUserModelID | `com.personal.fmp` | `com.personal.fmp.dev` | + | Windows 單一實例 mutex | `Local\FMP_MainInstance` | `Local\FMP_MainInstance-dev` | + | Windows 執行檔名 | `fmp.exe` | `fmp.exe`(flavor 只改身分,不改檔名) | + | 資料目錄 | ADR 0009 §決定 7 | 同一規則加 `-dev` | + + - Android namespace 維持 `com.personal.fmp`。 + - Android 簽名:本 PR 只用 debug 簽名;release 簽名在 PR 13 接 `key.properties`,照舊版的做法。 + - Windows 怎麼依 flavor 設 AUMID 與 mutex,用 Flutter 3.47.5 官方支援的桌面 flavor 機制。先以 context7 或官方文件查證 `--flavor` 在 Windows 的支援方式,並把查證來源寫進 PR 描述。官方不支援時,改用 CMake 的 `FLUTTER_FLAVOR` 或 `--dart-define`,但 C++ 端要能在建立視窗前拿到值。 + - 單一實例:第二個實例把既有視窗帶到前景後結束,行為照舊版 `windows/runner/main.cpp:12-66`。 + - dev 的標記圖示:本 PR 只改名稱,圖示留到 PR 12。 +3. **資料目錄**(`lib/platform/` 最小實作;完整的平台層在 PR 4): + - 擁有者 2026-09-29 決定:Windows Portable 版用程式旁的 `userdata/`(dev 為 `userdata-dev/`),不用 ADR 0009 原寫的 `data/`,因為它和 Flutter 的 `data/` 程式資源資料夾同名。 + - 只做「App 資料目錄」這一項能力:Android 私有目錄;Windows 安裝版 `%APPDATA%`、Portable 版程式旁 `data/`;dev 加 `-dev`。 + - **開發版拒絕舊版正式資料位置**:解析出的路徑若等於舊版資料位置或在它底下,直接拋錯。舊版位置要到舊專案 `lib/` 裡查(例如 `Documents\FMP`、舊 Android 私有目錄),寫進 dartdoc 並附檔名與行號。 +4. **零聯網兩道防線**(ADR 0015 §決定 3): + - `app/dart_test.yaml`:`tags: live: skip:`,並用 preset 解除; + - `app/test/flutter_test_config.dart`:以 `HttpOverrides.global` 讓建立真實 `HttpClient` 直接拋錯; + - 測試:一個標 `live` 的測試在裸 `flutter test` 被跳過;以 preset 解除後,它被 `HttpOverrides` 擋下(斷言錯誤訊息)。 +5. **測試**: + - prod 身分的每一項等於上表,dev 的每一項都不同; + - Android 讀 `build.gradle.kts` 產出的值,或以 Gradle task 檢查;Windows 讀 C++/CMake 設定的常數,方式不拘,但要能在 CI 的 Linux 跑; + - 開發版拒絕舊版資料位置; + - 零聯網兩道防線。 +6. **`material_ui`**:查 3.47.5 的官方文件,決定 Material 元件的 import 路徑,寫進 `app/AGENTS.md`。 +7. **`app/AGENTS.md`**(繁中): + - 只寫查不到的契約與有閘門的規則;沒有規則守的不寫; + - 包含: + - 驗證段:本 PR 起的指令;ADR 0027 的實機驗證三句(預設重播或測試插件、真實連線的條件、Android 與 Windows 每個使用者可見的 PR),skill 在 PR 11 前尚未提供,照實寫; + - 身分表; + - 零聯網; + - `material_ui`。 +8. **spec**:建 `.trellis/spec/app/testing/index.md`(繁中),寫零聯網與測試分層;不建 `.trellis/spec/app/index.md`(父任務 design §1)。 +9. **CI**(`.github/workflows/ci.yml`): + - `dorny/paths-filter@v4`(釘 commit SHA,照現有 action 的寫法); + - 觸發:`app/**` 與 `.github/**` 跑 `app` 的 job,`app/` 以外有變動就跑舊專案的三個 job; + - 舊 job 內容不動; + - `app` job:`dart format --output=none --set-exit-if-changed .`、`flutter analyze`、`flutter test`,Flutter 3.47.5; + - 最後一個 `always()` 彙總 job:needs 全部 job,被跳過的算通過,失敗或取消的算失敗; + - 檔頭註解改寫:說明為什麼現在有路徑過濾,以及為什麼文件變更仍會跑舊 job。 +10. **`orca.yaml`**:setup 加上 `app/` 的 `flutter pub get`(本 PR 還沒有 slang)。每行都要是 cmd 與 bash 共通的單一指令(照現有註解)。 +11. **Trellis 本機 agent 檔**:`trellis-check.md`、`trellis-implement.md` 第 2 步的 format/analyze 指令依 package 分流: + - `legacy` 維持現狀; + - `app` 在 `app/` 內跑 `dart format --output=none --set-exit-if-changed .` 與 `flutter analyze`。 +12. **根目錄 `AGENTS.md`**:地圖補上兩列: + - `app/` 一列(規則讀 `app/AGENTS.md`); + - `.github/workflows/` 改寫成「`ci.yml` 依路徑分流兩個專案;`release.yml` 發舊專案」。 + + 只寫合併後為真的事。 + +## 驗收 + +- [ ] 在 `app/` 內 `flutter analyze` 零問題、`flutter test` 全綠;零聯網兩道防線的測試存在並通過。 +- [ ] 舊專案 `flutter test --exclude-tags live` 在 Flutter 3.47.5 下仍全綠(本機)。 +- [ ] Windows: + - `flutter build windows --flavor dev` 與 `--flavor prod` 都能建置; + - 同時開啟時兩個都在,各自的 AUMID 不同(主對話驗證); + - 同一 flavor 開第二次,會把第一個帶到前景。 +- [ ] Android:`flutter build apk --flavor dev --debug` 與 `--flavor prod --debug`;兩個 App 能同時安裝在模擬器(主對話驗證)。 +- [ ] PR 的 CI: + - `app` job 與舊 job 都有跑(這個 PR 同時改到 `app/` 和根目錄); + - 彙總 job 綠; + - 再以一個只改 `app/` 的 commit 驗證舊 job 被跳過、彙總仍綠(可在 PR 內做)。 diff --git a/.trellis/tasks/archive/2026-09/09-29-app-skeleton/research/notes.md b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/research/notes.md new file mode 100644 index 000000000..464b731c2 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/research/notes.md @@ -0,0 +1,96 @@ +# M1 PR 2 查證紀錄 + +- 查證日期:2026-09-29。本機 Flutter 3.47.5(`6a19cca564`)/Dart 3.13.4。 +- 官方文件以 `gh api repos/flutter/website/contents/...`、`repos/dart-lang/site-www/contents/...` + 讀原始 Markdown;SDK 與套件行為讀本機 Flutter SDK 與 pub cache 的原始碼。 + +## 1. Windows flavor + +- https://docs.flutter.dev/deployment/flavors-windows + (`flutter/website` `sites/docs/src/content/deployment/flavors-windows.md`): + - 「Built-in support for flavors on Windows requires Flutter 3.47 or later.」 + - `--flavor` 時 flutter 把 flavor 寫進 `windows/flutter/ephemeral/generated_config.cmake` 的 + `FLUTTER_APP_FLAVOR`;它在頂層 `CMakeLists.txt` 的 `add_subdirectory(${FLUTTER_MANAGED_DIR})` + 之後才可見,`windows/runner/CMakeLists.txt` 讀得到。 + - 建置目錄依 flavor 分開:`build/windows///runner//`。 + - 「If you omit `--flavor`, `appFlavor` matches the default flavor configured in `pubspec.yaml`.」 + - 客製原生設定的建議做法是在 `runner/CMakeLists.txt` 依 `FLUTTER_APP_FLAVOR` 設值。 + 文件用 `configure_file` 產生到原始碼目錄;本 PR 改用 `target_compile_definitions` + (Flutter 範本傳 `FLUTTER_VERSION` 給 `main.cpp` 與 `Runner.rc` 的同一個機制), + 不產生要 gitignore 的檔案。 +- 實作 PR:flutter/flutter#187034(commit `0145a67ea8f`,`git tag --contains` 從 3.47.0 起)。 + 合併版的 `lib/src/cmake.dart:110-117` 註明「The default template does not use it」—— + PR 描述裡「BINARY_NAME 加 `-` 後綴」的範本改動沒有進 3.47.5 的範本 + (`templates/app/windows.tmpl/CMakeLists.txt.tmpl:7` 仍是 `set(BINARY_NAME "{{projectName}}")`), + 所以兩個 flavor 的執行檔都是 `fmp.exe`,符合 prd。 +- `default-flavor` 的解析:`flutter_tools/lib/src/runner/flutter_command.dart:1498-1503`, + `cliFlavor ?? defaultFlavor`,並加進 dart-define `FLUTTER_APP_FLAVOR`;Windows、Linux、 + macOS、Android、iOS 裝置的 `supportsFlavors` 都是 true。 +- 實測:`flutter build windows --flavor dev`/`--flavor prod` 的 exe 版本資源與寬字串: + dev `ProductName=fmp-dev`、`FileDescription=FMP Dev`、`com.personal.fmp.dev`、 + `Local\FMP_MainInstance-dev`;prod `fmp`、`FMP`、`com.personal.fmp`、`Local\FMP_MainInstance`。 + +## 2. Android flavor + +- https://docs.flutter.dev/deployment/flavors(`.../deployment/flavors.md`): + - Kotlin DSL:`flavorDimensions += "default"`、`productFlavors { create("staging") { dimension = ...; applicationIdSuffix = ".staging" } }`。 + - App 名稱放 `src//res/values/strings.xml`,manifest 的 `android:label="@string/app_name"`。 + - 「If your project sets `app_name` with `resValue()` … builds with AGP 9.0 or later fail by default」→ 不用 `resValue()`。 +- 實測(`aapt2 dump badging`):`app-dev-debug.apk` 是 `com.personal.fmp.dev`/`FMP Dev`, + `app-prod-debug.apk` 是 `com.personal.fmp`/`FMP`。 + +## 3. `material_ui` + +- https://docs.flutter.dev/release/breaking-changes/material-ui-and-cupertino-ui + (`.../release/breaking-changes/material-ui-and-cupertino-ui.md`): + - 「In Flutter 3.47, developers can opt in to these packages ahead of the formal deprecation + of the in-framework design libraries.」框架內的 Material/Cupertino 自 3.44 凍結。 + - 手動遷移:`flutter pub add material_ui`,`import 'package:material_ui/material_ui.dart';`。 +- pub.dev API:`material_ui` 1.5.0(2026-09-28),`sdk ^3.13.0`、`flutter >=3.47.0`。 +- SDK 內的 data-driven fix:`packages/flutter/lib/fix_data/fix_material/fix_material.yaml:30-35` + 把 `package:flutter/material.dart` 換成 `package:material_ui/material_ui.dart`。 + +## 4. pub workspace + +- https://dart.dev/tools/pub/workspaces(`dart-lang/site-www` `src/content/tools/pub/workspaces.md`): + 根 pubspec 以 `workspace:` 列成員,成員寫 `resolution: workspace`;Dart 3.11 起可用 glob。 +- 實測:`workspace: []` 在 `flutter pub get` 下可解析。 + +## 5. 零聯網 + +- package:test 的 preset(pub cache `test-1.31.2/doc/configuration.md:845-877`):tag 內可寫 + `presets: {force: {skip: false}}`,以 `-P force` 解除。 +- **`flutter test` 沒有 `--preset`**:`flutter_tools/lib/src/test/runner.dart:75-91` 組給 + package:test 的參數只有 `--tags`/`--exclude-tags`/`--run-skipped` 等,沒有 preset; + `flutter test -h` 同樣沒有。所以 ADR 0015 §決定 3 的「以 preset 解除」在 `flutter test` + 做不到,改用 `flutter test --run-skipped --tags live`。實測:裸 `flutter test` 顯示該條 + `Skip: live: …`;`--run-skipped --tags live` 時該條執行並通過(斷言被第二道擋下)。 +- flutter_test 的 binding 初始化時會設 `HttpOverrides.global = _MockHttpOverrides()` + (`packages/flutter_test/lib/src/_binding_io.dart:26-28`,由 `binding.dart:1289` 呼叫)。 + 所以 `flutter_test_config.dart` 先 `TestWidgetsFlutterBinding.ensureInitialized()` 再設自己的 + override。實測:拿掉那一行後 `test/zero_network_test.dart` 有 3 條失敗。 + +## 6. 資料目錄與舊版位置 + +- `path_provider_windows` 2.3.0 `lib/src/path_provider_windows_real.dart:119-120,180-210`: + application support 是 RoamingAppData 下的 `\`(取自 exe 版本資源)。 +- `flutter_secure_storage_windows` 4.1.0 `lib/src/flutter_secure_storage_windows_ffi.dart:234,253`: + `flutter_secure_storage.dat` 寫在 application support。舊版 `Runner.rc:92,98` 是 + `com.personal`/`fmp`,所以舊版憑證在 `%APPDATA%\com.personal\fmp`。 +- 舊版資料庫與 log:`lib/data/database/database_provider.dart:15,23-24,51`、 + `lib/core/log_file_sink.dart:25-26`(`getApplicationDocumentsDirectory()` 下的 `FMP`)。 +- 安裝版判斷:ADR 0022 §決定 5(程式目錄有 `unins000.exe`),與舊版 + `lib/services/update/update_service.dart:54-57` 相同。 + +## 7. CI + +- `dorny/paths-filter` v4.0.3 → `ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d` + (`gh api repos/dorny/paths-filter/git/ref/tags/v4.0.3`,輕量 tag 直指 commit)。 +- README(v4.0.3):`pull_request` 以 REST API 取變動清單,需要 `pull-requests: read`; + push 以 git 比對,需先 checkout;推到 base 同一分支時與推送前的 commit 比。 + `predicate-quantifier: some-with-excludes` 才能表達「符合 `**` 且不符合 `!app/**`」。 + +## 8. 建置時發現 + +- MSVC 以系統字碼頁(本機 950)讀 `main.cpp`,繁中註解觸發 C4819,Flutter 範本的 + 警告即錯誤讓建置失敗。`runner/CMakeLists.txt` 對 CXX 加 `/utf-8`。 diff --git a/.trellis/tasks/archive/2026-09/09-29-app-skeleton/task.json b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/task.json new file mode 100644 index 000000000..80d715cb6 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-app-skeleton/task.json @@ -0,0 +1,26 @@ +{ + "id": "app-skeleton", + "name": "app-skeleton", + "title": "app/ 骨架與 CI 分流", + "description": "M1 PR 2: create app/ Flutter project with dev/prod flavors, identity, single-instance lock, zero-network test defences, app/AGENTS.md, CI split with paths-filter and an always() summary job, orca setup", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-29", + "completedAt": "2026-09-29", + "branch": "feat/app-skeleton", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b5c6c0728..0bb83efc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,11 @@ ADR 0026). Human-facing docs live in `docs/`; `docs/README.md` is the map. | Path | What | Rules to read first | |------|------|---------------------| | `lib/`, `test/`, `tool/`, `android/`, `windows/`, `assets/`, root `pubspec.yaml` | The old app — frozen, hotfixes only | `lib/AGENTS.md` | +| `app/` | The new app — its own Flutter project and pub workspace root | `app/AGENTS.md` | | `docs/adr/` | Decisions. 0008 onward is the rewrite; 0001–0007 describe the old app only | — | -| `.github/workflows/` | `ci.yml` and `release.yml` build and release the old app | — | +| `.github/workflows/` | `ci.yml` splits by changed path — `app/**` and `.github/**` run the `app` job, anything outside `app/` runs the old app's jobs — and `CI Result` fails if any of them did; `release.yml` releases the old app | — | | `.claude/skills/` | `verify-legacy-on-device` for old-app hotfixes; `trellis-*` come with Trellis | — | -| `.trellis/` | Tasks and specs: `spec/legacy/` for the old app, `spec/guides/` shared | — | +| `.trellis/` | Tasks and specs: `spec/legacy/` for the old app, `spec/app/` for the new one, `spec/guides/` shared | — | Claude Code loads a subdirectory's `AGENTS.md` only when it reads a file there, so open `lib/AGENTS.md` yourself before an old-app change that touches only diff --git a/analysis_options.yaml b/analysis_options.yaml index ff94c8695..17216fb33 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,6 +19,10 @@ analyzer: - web/** - macos/** - linux/** + # 新專案:自己的 pubspec 與 analysis_options,由 CI 的 `app` job 分析。沒排除 + # 的話,舊專案 job 沒對 app/ 跑 pub get,它的 package import 全報 + # uri_does_not_exist。 + - app/** linter: # The lint rules applied to this project can be customized in the diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..79f7ecabd --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,48 @@ +# 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/ + +# 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 + +# Widget Preview related +.widget_preview/ diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 000000000..9975808df --- /dev/null +++ b/app/.metadata @@ -0,0 +1,42 @@ +# 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: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: android + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: ios + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: linux + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: macos + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: windows + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + + # 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/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 000000000..4385bec4b --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,90 @@ +# app/AGENTS.md + +新 App(ADR 0008)。每條行為以 `docs/adr/` 的 ADR 為準;這裡只寫從程式碼查不到的 +契約,以及有閘門守著的規則(每條寫出它的閘門)。 + +## 驗證 + +在 `app/` 內執行: + +| 改動 | 最少要跑 | +|---|---| +| 任何改動 | `dart format --output=none --set-exit-if-changed .`、`flutter analyze`、`flutter test` | +| 原生身分(`android/app/`、`windows/runner/`) | 上一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | + +- `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑的就是 + 上表第一列。 +- `test/identity/windows_identity_test.dart` 要 `cmake`:CI 的 ubuntu runner 內建; + Windows 的 PATH 上沒有時,測試以 vswhere 找 Visual Studio 附的那一份。 +- 不帶 `--flavor` 的 run/build 是 dev(`pubspec.yaml` 的 `default-flavor`)。 + +### 實機驗證 + +- 預設用 dev flavor 加測試插件,或把插件切成重播模式(ADR 0027 §決定 1)。 +- 只有改動本身是插件、網路層、登入或正在錄 fixture 時才用真實連線,只做最少的 + 操作,回報寫明「模式:真實」與做了哪些請求(ADR 0027 §決定 2)。 +- 每個使用者看得到的 PR 都要在 Android 模擬器與 Windows 驗(ADR 0027 §決定 3)。 +- `app/` 版的 `verify-on-device` skill 在 M1 PR 11 才建立;在那之前照上面三條手動 + 驗,回報寫明平台與模式。根目錄的 `verify-legacy-on-device` 只給舊專案用。 + +## App 身分 + +prod 與舊版相同,舊資料與憑證才讀得到(ADR 0008 §決定 3),改任何一項都要另立 +ADR;dev 每一項都不同(ADR 0015 §決定 8)。 + +| 項目 | prod | dev | 設定在 | +|---|---|---|---| +| Android `applicationId` | `com.personal.fmp` | `com.personal.fmp.dev` | `android/app/build.gradle.kts` | +| Android App 名稱 | `FMP` | `FMP Dev` | `android/app/src//res/values/strings.xml` | +| Windows AppUserModelID | `com.personal.fmp` | `com.personal.fmp.dev` | `windows/runner/app_identity.cmake` | +| Windows 單一實例 mutex | `Local\FMP_MainInstance` | `Local\FMP_MainInstance-dev` | 同上(`Local\` 前綴在 `main.cpp`) | +| Windows 視窗標題、FileDescription | `FMP` | `FMP Dev` | 同上 | +| Windows ProductName | `fmp` | `fmp-dev` | 同上 | +| Windows 執行檔 | `fmp.exe` | `fmp.exe` | `windows/CMakeLists.txt` 的 `BINARY_NAME` | + +閘門:`test/identity/android_identity_test.dart`、`test/identity/windows_identity_test.dart`。 +沒有測試的兩處:mutex 的 `Local\` 前綴,以及 `main.cpp`/`Runner.rc` 確實讀這些定義; +改到它們時,檢查建置出的 exe 的版本資源與內嵌的寬字串。 + +- Android namespace 兩個 flavor 都是 `com.personal.fmp`;只有 applicationId 帶後綴。 +- Windows 的第二個實例以「視窗類別+標題」找第一個實例帶到前景,所以兩個 flavor + 的標題必須不同;之後若在 Dart 端改視窗標題,要一併改 `main.cpp` 的尋找方式。 +- Windows 的 ProductName 決定 path_provider 的目錄(`%APPDATA%\com.personal\`), + dev 的 application support、cache 等目錄因此全部與 prod 分開。 + +## 資料目錄 + +`lib/platform/app_data_directory/`(ADR 0009 §決定 7)。閘門: +`test/platform/app_data_directory_test.dart`。 + +- Windows 以程式目錄有沒有 `unins000.exe` 分安裝版與免安裝版(ADR 0022 §決定 5)。 + `flutter run` 出來的是免安裝版,dev 的資料在 `build/windows/x64/dev/runner/<模式>/userdata-dev/`, + `flutter clean` 會一起清掉。 +- dev 解析到舊版正式資料的位置(Windows 的 `Documents\FMP` 與 + `%APPDATA%\com.personal\fmp`、Android 舊版沙盒 `com.personal.fmp`)或其下,`main()` 在 + `runApp` 之前就拋 `LegacyDataLocationException`:Windows 的原生視窗仍會開,但內容空白。 + +## 零聯網 + +ADR 0015 §決定 3 的兩道防線: + +- `dart_test.yaml` 讓 `live` tag 預設跳過。`flutter test` 沒有 package:test 的 + `--preset`,解除用 `flutter test --run-skipped --tags live`。 +- `test/flutter_test_config.dart` 讓建立真實 `HttpClient` 直接拋錯;要聯網的測試在自己 + 的 zone 以 `HttpOverrides.runWithHttpOverrides` 放行。 + +閘門:`test/zero_network_test.dart`——讀 `dart_test.yaml` 確認 `live` 預設跳過;斷言 +建立 `HttpClient` 會被擋(含 widget 測試的 binding 之下);另有一條 `live` 測試,解除 +跳過後斷言仍被第二道擋下。 + +## Material + +Flutter 3.47 起 Material 以獨立套件 `material_ui` 發佈,框架內的 +`package:flutter/material.dart` 已凍結、之後會棄用 +(https://docs.flutter.dev/release/breaking-changes/material-ui-and-cupertino-ui)。 +`app/` 一律 `import 'package:material_ui/material_ui.dart'`;`widgets.dart`、 +`services.dart` 等非設計系統的函式庫仍從 `package:flutter/` 匯入。Cupertino 同理用 +`cupertino_ui`,需要時再加依賴。 + +閘門:`test/static_rules/material_import_static_rule_test.dart`(掃 `lib/` 與 +`test/`)。`fmp_lints`(M1 PR 3)接手後刪掉它。 diff --git a/app/README.md b/app/README.md new file mode 100644 index 000000000..7ef6540c9 --- /dev/null +++ b/app/README.md @@ -0,0 +1,4 @@ +# FMP(重寫中的新 App) + +根目錄舊專案的替代品,還在開發中(ADR 0008)。開發規則與驗證指令見 +[AGENTS.md](AGENTS.md),架構決定見 [docs/adr/](../docs/adr/)。 diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 000000000..c7f49779c --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,37 @@ +# 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 + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** + +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/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 000000000..be3943c96 --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 000000000..9354c916d --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.personal.fmp" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // prod 的 applicationId 與舊版相同(ADR 0008 §決定 3),舊資料與 + // Keystore 憑證才讀得到;改它要另立 ADR。 + applicationId = "com.personal.fmp" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // release 簽名在發版工作接 key.properties(M1 PR 13),先用 debug 簽名。 + signingConfig = signingConfigs.getByName("debug") + } + } + + // flavor 只改身分(app/AGENTS.md § App 身分);App 名稱在 + // src//res/values/strings.xml。AGP 9 起 resValue() 預設停用,所以 + // 不用它(https://docs.flutter.dev/deployment/flavors)。 + flavorDimensions += "identity" + productFlavors { + create("dev") { + dimension = "identity" + applicationIdSuffix = ".dev" + } + create("prod") { + dimension = "identity" + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/dev/res/values/strings.xml b/app/android/app/src/dev/res/values/strings.xml new file mode 100644 index 000000000..aa00a9fdb --- /dev/null +++ b/app/android/app/src/dev/res/values/strings.xml @@ -0,0 +1,4 @@ + + + FMP Dev + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4bff20e23 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/personal/fmp/MainActivity.kt b/app/android/app/src/main/kotlin/com/personal/fmp/MainActivity.kt new file mode 100644 index 000000000..6be64e0ad --- /dev/null +++ b/app/android/app/src/main/kotlin/com/personal/fmp/MainActivity.kt @@ -0,0 +1,5 @@ +package com.personal.fmp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..f74085f3f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..304732f88 --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..db77bb4b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..17987b79b Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..09d439148 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d5f1c8d34 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4d6372eeb Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..06952be74 --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..cb1ef8805 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/prod/res/values/strings.xml b/app/android/app/src/prod/res/values/strings.xml new file mode 100644 index 000000000..894ca5ea0 --- /dev/null +++ b/app/android/app/src/prod/res/values/strings.xml @@ -0,0 +1,4 @@ + + + FMP + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 000000000..dbee657bb --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 000000000..e96108cfe --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a20f2c46d --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 000000000..b28021a91 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/app/dart_test.yaml b/app/dart_test.yaml new file mode 100644 index 000000000..13627d432 --- /dev/null +++ b/app/dart_test.yaml @@ -0,0 +1,7 @@ +tags: + # 打真實 API 的測試。測試預設零聯網(ADR 0015 §決定 3):`flutter test` 一律 + # 跳過;要跑時明確解除: + # flutter test --run-skipped --tags live + # (flutter test 沒有 package:test 的 --preset,解除只能靠 --run-skipped。) + live: + skip: "live: hits real APIs; run with --run-skipped --tags live" diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 000000000..7a7f9873a --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000..391a902b2 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000..592ceee85 --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000..592ceee85 --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..0acf504f2 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..c3fedb29c --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..1d526a16e --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000..c30b367ec --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..d36b1fab2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000..dc9ada472 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000..7353c41ec Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000..797d452e4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000..6ed2d933e Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000..4cd7b0099 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000..fe730945a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000..321773cd8 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000..797d452e4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000..502f463a9 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000..0ec303439 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000..0ec303439 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000..e9f5fea27 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000..84ac32ae7 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000..8953cba09 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000..0467bf12a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000..0bedcf2fd --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000..89c2725b7 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..f2e259c7c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..f3c28516f --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 000000000..eb9999d6e --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Fmp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + fmp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000..308a2a560 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift new file mode 100644 index 000000000..b9ce8ea2b --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..86a7c3b1b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +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/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart new file mode 100644 index 000000000..d84e0ad5e --- /dev/null +++ b/app/lib/app/fmp_app.dart @@ -0,0 +1,48 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/core/app_flavor.dart'; + +/// App 根元件。目前只顯示 App 名稱、flavor 與資料目錄,供實機確認身分; +/// 正式的外殼在 M1 PR 12。 +class FmpApp extends StatelessWidget { + const FmpApp({super.key, required this.flavor, this.dataDirectoryPath}); + + final AppFlavor flavor; + + /// 平台沒有資料目錄實作時為 `null`。 + final String? dataDirectoryPath; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: flavor.displayName, + home: _IdentityPage(flavor: flavor, dataDirectoryPath: dataDirectoryPath), + ); + } +} + +class _IdentityPage extends StatelessWidget { + const _IdentityPage({required this.flavor, required this.dataDirectoryPath}); + + final AppFlavor flavor; + final String? dataDirectoryPath; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + flavor.displayName, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text(flavor.name), + if (dataDirectoryPath case final path?) SelectableText(path), + ], + ), + ), + ); + } +} diff --git a/app/lib/core/app_flavor.dart b/app/lib/core/app_flavor.dart new file mode 100644 index 000000000..9ee7a5a25 --- /dev/null +++ b/app/lib/core/app_flavor.dart @@ -0,0 +1,24 @@ +/// App 的建置版本(Flutter flavor)。 +/// +/// 值來自 `package:flutter/services.dart` 的 `appFlavor`:`--flavor` 或 +/// `pubspec.yaml` 的 `default-flavor: dev`。原生端的身分(Android +/// applicationId、Windows AUMID、單一實例鎖)由同一個 flavor 在建置時決定, +/// 見 `app/AGENTS.md` § App 身分。 +enum AppFlavor { + dev, + prod; + + /// 解析 `appFlavor`。沒有值或不認得就拋錯:不猜是哪個版本,免得開發版 + /// 誤用正式版的資料。 + static AppFlavor parse(String? name) => switch (name) { + 'dev' => dev, + 'prod' => prod, + _ => throw ArgumentError.value(name, 'appFlavor', 'expected dev or prod'), + }; + + /// 使用者看到的 App 名稱,與原生端的顯示名稱一致(身分測試核對)。 + String get displayName => switch (this) { + prod => 'FMP', + dev => 'FMP Dev', + }; +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 000000000..1bd2776b5 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,13 @@ +import 'package:flutter/services.dart' show appFlavor; +import 'package:flutter/widgets.dart'; + +import 'package:fmp/app/fmp_app.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final flavor = AppFlavor.parse(appFlavor); + final dataDirectory = await appDataDirectoryFor(flavor)?.resolve(); + runApp(FmpApp(flavor: flavor, dataDirectoryPath: dataDirectory?.path)); +} diff --git a/app/lib/platform/app_data_directory/app_data_directory.dart b/app/lib/platform/app_data_directory/app_data_directory.dart new file mode 100644 index 000000000..22439b97c --- /dev/null +++ b/app/lib/platform/app_data_directory/app_data_directory.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory_android.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory_windows.dart'; + +/// App 資料目錄(資料庫、設定、log)的能力(ADR 0009 §決定 7)。 +/// +/// 開發版與正式版各用一個目錄(ADR 0015 §決定 8),而且開發版拒絕舊版正式 +/// 資料的位置,見 [ensureOutsideLegacyData]。 +abstract interface class AppDataDirectory { + /// 解析並建立目錄。 + Future resolve(); +} + +/// 目前平台的實作;平台沒有實作時回 `null`。 +/// +/// 只有 Android 與 Windows 有實作。其他平台驗證前不寫實作 +/// (ADR 0009 §決定 4)。 +AppDataDirectory? appDataDirectoryFor(AppFlavor flavor) { + if (Platform.isAndroid) { + return AndroidAppDataDirectory( + flavor: flavor, + applicationSupportPath: () async => + (await getApplicationSupportDirectory()).path, + ); + } + if (Platform.isWindows) { + return WindowsAppDataDirectory( + flavor: flavor, + executablePath: Platform.resolvedExecutable, + roamingAppDataPath: Platform.environment['APPDATA'], + applicationSupportPath: () async => + (await getApplicationSupportDirectory()).path, + documentsPath: () async => + (await getApplicationDocumentsDirectory()).path, + ); + } + return null; +} + +/// 開發版解析出的資料目錄等於或位於舊版正式資料位置之下時拋出。 +final class LegacyDataLocationException implements Exception { + const LegacyDataLocationException({ + required this.path, + required this.legacyLocation, + }); + + final String path; + final String legacyLocation; + + @override + String toString() => + 'LegacyDataLocationException: the dev flavor refuses data directory ' + '$path because it is inside the legacy data location $legacyLocation'; +} + +/// [path] 等於或位於任一 [legacyLocations] 之下就拋 +/// [LegacyDataLocationException]。 +/// +/// 只給開發版用:正式版之後要從這些位置匯入舊資料(ADR 0010)。 +void ensureOutsideLegacyData(String path, Iterable legacyLocations) { + for (final legacy in legacyLocations) { + if (p.equals(path, legacy) || p.isWithin(legacy, path)) { + throw LegacyDataLocationException(path: path, legacyLocation: legacy); + } + } +} diff --git a/app/lib/platform/app_data_directory/app_data_directory_android.dart b/app/lib/platform/app_data_directory/app_data_directory_android.dart new file mode 100644 index 000000000..e161657d2 --- /dev/null +++ b/app/lib/platform/app_data_directory/app_data_directory_android.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; + +/// Android:App 私有目錄(`Context.getFilesDir()`,即 path_provider 的 +/// application support)。 +/// +/// 私有目錄本來就依 applicationId 分開,開發版的 `.dev` 後綴 +/// (`android/app/build.gradle.kts`)已經讓它落在另一個目錄,Dart 端不再加 +/// `-dev`。 +/// +/// 舊版正式資料:舊版 applicationId 是 `com.personal.fmp`(舊專案 +/// `android/app/build.gradle.kts:29`),整個沙盒 +/// `/data/user/<使用者>/com.personal.fmp/` 都是舊資料;資料庫在其下的 +/// `app_flutter/FMP/`(舊專案 `lib/data/database/database_provider.dart:15`、 +/// `:51`)。開發版的目錄若落在那裡(例如 flavor 的 applicationId 後綴被拿掉), +/// [resolve] 直接拋錯。 +final class AndroidAppDataDirectory implements AppDataDirectory { + AndroidAppDataDirectory({ + required this.flavor, + required this._applicationSupportPath, + }); + + /// 舊版的 applicationId,也是它私有目錄的目錄名。 + static const legacyApplicationId = 'com.personal.fmp'; + + final AppFlavor flavor; + final Future Function() _applicationSupportPath; + + @override + Future resolve() async { + // `<資料根>//files` + final path = await _applicationSupportPath(); + if (flavor == AppFlavor.dev) { + final dataRoot = p.dirname(p.dirname(path)); + ensureOutsideLegacyData(path, [p.join(dataRoot, legacyApplicationId)]); + } + return Directory(path).create(recursive: true); + } +} diff --git a/app/lib/platform/app_data_directory/app_data_directory_windows.dart b/app/lib/platform/app_data_directory/app_data_directory_windows.dart new file mode 100644 index 000000000..8950aa5be --- /dev/null +++ b/app/lib/platform/app_data_directory/app_data_directory_windows.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; + +/// Windows:安裝版用 `%APPDATA%` 下的 App 目錄,免安裝版用程式旁的 +/// `userdata/`(ADR 0009 §決定 7 與其更正)。Flutter 自己在程式旁放 `data/` +/// (`flutter_assets`、`app.so`),使用者資料不能混進去,否則免安裝版整目錄更新 +/// 時會一起被換掉。 +/// +/// - 安裝版的判斷照 ADR 0022 §決定 5:程式目錄有 `unins000.exe`。 +/// - 安裝版的目錄是 path_provider 的 application support, +/// `%APPDATA%\\`。開發版的 ProductName 是 +/// `fmp-dev`(`windows/runner/app_identity.cmake`),目錄因此分開。 +/// - 免安裝版的開發版用 `userdata-dev/`。 +/// +/// 舊版正式資料有兩處,開發版解析到其中任一處或其下就拋錯: +/// - `Documents\FMP`:資料庫與 log(舊專案 +/// `lib/data/database/database_provider.dart:15`、`:51`, +/// `lib/core/log_file_sink.dart:25-26`)。 +/// - `%APPDATA%\com.personal\fmp`:舊版的 secure storage 檔 +/// (舊專案 `lib/core/secure_key_value_store.dart:43` 用 +/// flutter_secure_storage,Windows 實作寫在 application support; +/// CompanyName/ProductName 見舊專案 `windows/runner/Runner.rc:92`、`:98`)。 +final class WindowsAppDataDirectory implements AppDataDirectory { + WindowsAppDataDirectory({ + required this.flavor, + required this.executablePath, + required this.roamingAppDataPath, + required this._applicationSupportPath, + required this._documentsPath, + }); + + final AppFlavor flavor; + + /// 執行檔的完整路徑(`Platform.resolvedExecutable`)。 + final String executablePath; + + /// `%APPDATA%`;取不到時為 `null`,那一處舊版位置就無從比對。 + final String? roamingAppDataPath; + + final Future Function() _applicationSupportPath; + final Future Function() _documentsPath; + + @override + Future resolve() async { + final programDirectory = p.dirname(executablePath); + final installed = await File(p.join(programDirectory, 'unins000.exe')) + .exists(); + final path = installed + ? await _applicationSupportPath() + : p.join( + programDirectory, + flavor == AppFlavor.dev ? 'userdata-dev' : 'userdata', + ); + + if (flavor == AppFlavor.dev) { + final roaming = roamingAppDataPath; + ensureOutsideLegacyData(path, [ + p.join(await _documentsPath(), 'FMP'), + if (roaming != null) p.join(roaming, 'com.personal', 'fmp'), + ]); + } + return Directory(path).create(recursive: true); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 000000000..2dd03b840 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "fmp") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.personal.fmp") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..e71a16d23 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..be1ee3e5b --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 000000000..e97dabc70 --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 000000000..b0d65707a --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "fmp"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "fmp"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 000000000..db16367a7 --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 000000000..746adbb6b --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000..c2efd0b60 --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000..c2efd0b60 --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 000000000..cccf817a5 --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..2bde38387 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*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 */ + 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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* 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 */ + 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 /* fmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "fmp.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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage 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 */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* fmp.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 = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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 = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 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 = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* fmp.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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + 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 */ + 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"; + }; +/* 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; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fmp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fmp"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fmp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fmp"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fmp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fmp"; + }; + 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 = 12.0; + 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 = 12.0; + 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 = 12.0; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..6f5f23eb8 --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..1d526a16e --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000..b3c176141 --- /dev/null +++ b/app/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/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..a2ec33f19 --- /dev/null +++ b/app/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/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000..82b6f9d9a Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000..13b35eba5 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000..0a3f5fa40 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000..bdb57226d Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000..f083318e0 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000..326c0e72c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000..2f1632cfd Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000..80e867a4e --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000..74eab1c07 --- /dev/null +++ b/app/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 = fmp + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.personal.fmp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.personal. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000..36b0fd946 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000..dff4f4956 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000..42bcbf478 --- /dev/null +++ b/app/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/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..dddb8a30c --- /dev/null +++ b/app/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/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 000000000..4789daa6a --- /dev/null +++ b/app/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/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000..3cc05eb23 --- /dev/null +++ b/app/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/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 000000000..852fa1a47 --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..61f3bd1fc --- /dev/null +++ b/app/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/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 000000000..45add73b5 --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,426 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + 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.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "32d98f11cf5942a095a8c3e01b03e4de3b17941437713dcf36b1088fb62e23d2" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + 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.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: "direct dev" + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.4 <4.0.0" + flutter: ">=3.47.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 000000000..c783050c2 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,33 @@ +name: fmp +description: "FMP — Flutter music player (rewrite)." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.13.4 + +# pub workspace 的根(ADR 0015;成員從 PR 3 的 packages/fmp_lints 起加入)。 +# analysis_server_plugin 的 `plugins:` 只能寫在 package 或 workspace 根, +# 所以 app/ 本身就是根,不另設外層 pubspec。 +workspace: [] + +dependencies: + flutter: + sdk: flutter + # Flutter 3.47 起 Material 以獨立套件發佈,app/ 直接用它,不 import + # package:flutter/material.dart(app/AGENTS.md § Material)。 + material_ui: ^1.5.0 + path: ^1.9.1 + path_provider: ^2.1.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + yaml: ^3.1.4 + +flutter: + uses-material-design: true + # 沒帶 --flavor 的 run/build/test 一律是開發版(ADR 0015 §決定 8); + # 發版必須明確帶 --flavor prod。 + default-flavor: dev diff --git a/app/test/app/fmp_app_test.dart b/app/test/app/fmp_app_test.dart new file mode 100644 index 000000000..854558c84 --- /dev/null +++ b/app/test/app/fmp_app_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/fmp_app.dart'; +import 'package:fmp/core/app_flavor.dart'; + +void main() { + testWidgets('shows the app name, flavor and data directory', (tester) async { + await tester.pumpWidget( + const FmpApp(flavor: AppFlavor.dev, dataDirectoryPath: '/data/fmp-dev'), + ); + + expect(find.text('FMP Dev'), findsOneWidget); + expect(find.text('dev'), findsOneWidget); + expect(find.text('/data/fmp-dev'), findsOneWidget); + }); + + testWidgets('omits the data directory when the platform has none', ( + tester, + ) async { + await tester.pumpWidget(const FmpApp(flavor: AppFlavor.prod)); + + expect(find.text('FMP'), findsOneWidget); + expect(find.text('prod'), findsOneWidget); + }); +} diff --git a/app/test/core/app_flavor_test.dart b/app/test/core/app_flavor_test.dart new file mode 100644 index 000000000..86dd0c054 --- /dev/null +++ b/app/test/core/app_flavor_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; + +void main() { + test('parses the two flavors', () { + expect(AppFlavor.parse('dev'), AppFlavor.dev); + expect(AppFlavor.parse('prod'), AppFlavor.prod); + }); + + test('refuses a missing or unknown flavor instead of guessing', () { + expect(() => AppFlavor.parse(null), throwsArgumentError); + expect(() => AppFlavor.parse('staging'), throwsArgumentError); + }); +} diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart new file mode 100644 index 000000000..5d443b003 --- /dev/null +++ b/app/test/flutter_test_config.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// 測試預設零聯網的第二道防線(ADR 0015 §決定 3;第一道是 `dart_test.yaml` +/// 把 `live` tag 設成跳過)。 +/// +/// 建立真實 [HttpClient] 直接拋 [StateError]。要聯網的測試在自己的 zone 以 +/// `HttpOverrides.runWithHttpOverrides` 放行,見 +/// `.trellis/spec/app/testing/index.md`。 +/// +/// 先初始化測試 binding:它初始化時會把 [HttpOverrides.global] 換成 +/// flutter_test 自己的假 client(回 400),順序反過來這道防線就被蓋掉。 +Future testExecutable(FutureOr Function() testMain) async { + TestWidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = NoNetworkHttpOverrides(); + await testMain(); +} + +/// 建立 [HttpClient] 就拋錯的 [HttpOverrides]。 +final class NoNetworkHttpOverrides extends HttpOverrides { + /// 錯誤訊息的開頭,測試以它確認擋下的是這道防線。 + static const message = 'Real network access is blocked in tests'; + + @override + HttpClient createHttpClient(SecurityContext? context) { + throw StateError( + '$message (test/flutter_test_config.dart). Inject a fake client, or ' + 'allow it in the test zone with HttpOverrides.runWithHttpOverrides.', + ); + } +} diff --git a/app/test/identity/android_identity_test.dart b/app/test/identity/android_identity_test.dart new file mode 100644 index 000000000..18b88acae --- /dev/null +++ b/app/test/identity/android_identity_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; + +/// Android 的 App 身分(app/AGENTS.md § App 身分):prod 與舊版相同 +/// (ADR 0008 §決定 3),dev 每一項都不同(ADR 0015 §決定 8)。 +/// +/// Gradle 在 CI 的 Linux 上跑太慢,這裡讀 `android/app/build.gradle.kts` 與各 +/// flavor 的 `strings.xml` 算出每個 flavor 的值。讀原始碼的解析以最後一組 +/// 變異案例驗證:違規會被算出來、無關的格式改動不影響結果。 +void main() { + final gradle = File('android/app/build.gradle.kts').readAsStringSync(); + String appName(AppFlavor flavor) => parseAppName( + File('android/app/src/${flavor.name}/res/values/strings.xml') + .readAsStringSync(), + ); + + test('prod keeps the legacy identity', () { + expect(parseNamespace(gradle), 'com.personal.fmp'); + expect(parseApplicationId(gradle, AppFlavor.prod), 'com.personal.fmp'); + expect(appName(AppFlavor.prod), 'FMP'); + }); + + test('dev differs in every identity value', () { + expect(parseApplicationId(gradle, AppFlavor.dev), 'com.personal.fmp.dev'); + expect(appName(AppFlavor.dev), 'FMP Dev'); + }); + + test('the launcher label reads the flavor app name', () { + final manifest = File('android/app/src/main/AndroidManifest.xml') + .readAsStringSync(); + expect(manifest, contains('android:label="@string/app_name"')); + }); + + test('the Dart display name matches the Android app name', () { + for (final flavor in AppFlavor.values) { + expect(appName(flavor), flavor.displayName, reason: flavor.name); + } + }); + + group('parser mutations', () { + test('a removed dev suffix collapses dev onto the prod id', () { + final mutated = gradle.replaceFirst('applicationIdSuffix = ".dev"', ''); + expect(mutated, isNot(gradle)); + expect(parseApplicationId(mutated, AppFlavor.dev), 'com.personal.fmp'); + }); + + test('a flavor-level applicationId overrides the default', () { + final mutated = gradle.replaceFirst( + RegExp(r'create\("prod"\) \{'), + 'create("prod") {\n applicationId = "com.other"', + ); + expect(mutated, isNot(gradle)); + expect(parseApplicationId(mutated, AppFlavor.prod), 'com.other'); + }); + + test('reformatting and comments leave the values unchanged', () { + final reformatted = gradle + .replaceAll('create("dev") {', 'create( "dev" )\n { // dev\n') + .replaceAll(' = ', '=') + .replaceAll(' ', '\t'); + expect(reformatted, isNot(gradle)); + for (final flavor in AppFlavor.values) { + expect( + parseApplicationId(reformatted, flavor), + parseApplicationId(gradle, flavor), + ); + } + expect(parseNamespace(reformatted), parseNamespace(gradle)); + }); + + test('app name parsing ignores other strings and layout', () { + expect( + parseAppName( + '\n x\n' + ' FMP Dev', + ), + 'FMP Dev', + ); + }); + }); +} + +String parseNamespace(String gradle) => + _single(RegExp(r'^\s*namespace\s*=\s*"([^"]+)"', multiLine: true), gradle); + +/// defaultConfig 的 applicationId 加上該 flavor 的 applicationIdSuffix; +/// flavor 自己寫了 applicationId 就以它為準(Gradle 的規則)。 +String parseApplicationId(String gradle, AppFlavor flavor) { + final defaultConfig = _single( + RegExp(r'defaultConfig\s*\{([^{}]*)\}'), + gradle, + ); + final flavorBlock = _single( + RegExp('create\\(\\s*"${flavor.name}"\\s*\\)\\s*\\{([^{}]*)\\}'), + gradle, + ); + final id = RegExp(r'applicationId\s*=\s*"([^"]+)"'); + final suffix = RegExp(r'applicationIdSuffix\s*=\s*"([^"]*)"'); + final base = + id.firstMatch(flavorBlock)?.group(1) ?? _single(id, defaultConfig); + return base + (suffix.firstMatch(flavorBlock)?.group(1) ?? ''); +} + +String parseAppName(String stringsXml) => _single( + RegExp(r'([^<]*)'), + stringsXml, +); + +String _single(RegExp pattern, String source) { + final matches = pattern.allMatches(source).toList(); + if (matches.length != 1) { + throw StateError( + 'expected one match of ${pattern.pattern}, got ${matches.length}', + ); + } + return matches.single.group(1)!; +} diff --git a/app/test/identity/windows_identity_test.dart b/app/test/identity/windows_identity_test.dart new file mode 100644 index 000000000..27a1a5cf0 --- /dev/null +++ b/app/test/identity/windows_identity_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:path/path.dart' as p; + +/// Windows 的 App 身分(app/AGENTS.md § App 身分):prod 與舊版相同 +/// (ADR 0008 §決定 3),dev 每一項都不同(ADR 0015 §決定 8)。 +/// +/// 以 `cmake -P` 執行 `windows/runner/app_identity.cmake`,取得 runner 編進 +/// 執行檔的值。CI 的 Linux 有 cmake;Windows 的 PATH 上沒有時用 Visual Studio +/// 附的那一份。 +void main() { + const prod = { + 'FMP_APP_USER_MODEL_ID': 'com.personal.fmp', + 'FMP_SINGLE_INSTANCE_NAME': 'FMP_MainInstance', + 'FMP_DISPLAY_NAME': 'FMP', + 'FMP_PRODUCT_NAME': 'fmp', + }; + + test('prod keeps the legacy identity', () { + expect(runIdentity('prod').values, prod); + }); + + test('dev differs in every identity value', () { + final dev = runIdentity('dev').values; + expect(dev, { + 'FMP_APP_USER_MODEL_ID': 'com.personal.fmp.dev', + 'FMP_SINGLE_INSTANCE_NAME': 'FMP_MainInstance-dev', + 'FMP_DISPLAY_NAME': 'FMP Dev', + 'FMP_PRODUCT_NAME': 'fmp-dev', + }); + for (final key in prod.keys) { + expect(dev[key], isNot(prod[key]), reason: key); + } + }); + + test('the Dart display name matches the window title', () { + for (final flavor in AppFlavor.values) { + expect( + runIdentity(flavor.name).values['FMP_DISPLAY_NAME'], + flavor.displayName, + ); + } + }); + + test('an unknown or missing flavor fails the build', () { + for (final flavor in ['staging', null]) { + final result = runIdentity(flavor); + expect(result.exitCode, isNot(0), reason: '$flavor'); + expect(result.stderr, contains('expected dev or prod')); + } + }); + + group('executable name', () { + final cmakeLists = File('windows/CMakeLists.txt').readAsStringSync(); + + test('is fmp.exe for every flavor', () { + // flavor 不改 BINARY_NAME;flutter 工具也只讀這一行找執行檔。 + expect(binaryName(cmakeLists), 'fmp'); + }); + + test('a renamed binary is detected', () { + final mutated = cmakeLists.replaceFirst( + 'set(BINARY_NAME "fmp")', + 'set(BINARY_NAME "fmp-dev")', + ); + expect(mutated, isNot(cmakeLists)); + expect(binaryName(mutated), 'fmp-dev'); + }); + + test('spacing and comments do not change the result', () { + final reformatted = cmakeLists.replaceFirst( + 'set(BINARY_NAME "fmp")', + '# 執行檔名\n set(BINARY_NAME "fmp" ) ', + ); + expect(reformatted, isNot(cmakeLists)); + expect(binaryName(reformatted), 'fmp'); + }); + }); +} + +/// flutter 工具找 Windows 執行檔名的同一條規則(flutter_tools +/// `lib/src/cmake.dart` 的 `getCmakeExecutableName`)。 +String? binaryName(String cmakeLists) { + final pattern = RegExp(r'^\s*set\(BINARY_NAME\s*"(.*)"\s*\)\s*$'); + for (final line in cmakeLists.split('\n')) { + final match = pattern.firstMatch(line.trimRight()); + if (match != null) { + return match.group(1); + } + } + return null; +} + +typedef IdentityResult = ({ + int exitCode, + String stderr, + Map values, +}); + +IdentityResult runIdentity(String? flavor) { + final temp = Directory.systemTemp.createTempSync('fmp_windows_identity_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final identityFile = p + .join(Directory.current.path, 'windows', 'runner', 'app_identity.cmake') + .replaceAll(r'\', '/'); + final script = File(p.join(temp.path, 'print_identity.cmake')) + ..writeAsStringSync( + 'include("$identityFile")\n' + 'foreach(name FMP_APP_USER_MODEL_ID FMP_SINGLE_INSTANCE_NAME ' + 'FMP_DISPLAY_NAME FMP_PRODUCT_NAME)\n' + r' message(NOTICE "${name}=${${name}}")' + '\nendforeach()\n', + ); + final result = Process.runSync(_cmake, [ + if (flavor != null) '-DFLUTTER_APP_FLAVOR=$flavor', + '-P', + script.path, + ]); + final stderr = result.stderr as String; + final line = RegExp(r'^(FMP_\w+)=(.*)$'); + final values = { + for (final text in stderr.split(RegExp(r'\r?\n'))) + if (line.firstMatch(text) case final match?) + match.group(1)!: match.group(2)!, + }; + return (exitCode: result.exitCode, stderr: stderr, values: values); +} + +final String _cmake = _findCmake(); + +String _findCmake() { + try { + if (Process.runSync('cmake', ['--version']).exitCode == 0) { + return 'cmake'; + } + } on ProcessException { + // PATH 上沒有,改找 Visual Studio 附的 cmake(flutter 建置 Windows 也用它)。 + } + const vswhere = + r'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'; + if (File(vswhere).existsSync()) { + final found = Process.runSync(vswhere, [ + '-latest', + '-products', + '*', + '-find', + r'**\bin\cmake.exe', + ]); + final path = (found.stdout as String).trim().split(RegExp(r'\r?\n')).first; + if (path.isNotEmpty) { + return path; + } + } + throw StateError('cmake not found on PATH or via vswhere'); +} diff --git a/app/test/platform/app_data_directory_test.dart b/app/test/platform/app_data_directory_test.dart new file mode 100644 index 000000000..d1a95495c --- /dev/null +++ b/app/test/platform/app_data_directory_test.dart @@ -0,0 +1,211 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory_android.dart'; +import 'package:fmp/platform/app_data_directory/app_data_directory_windows.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + + setUp(() { + root = Directory.systemTemp.createTempSync('fmp_app_data_directory_'); + addTearDown(() => root.deleteSync(recursive: true)); + }); + + group('Windows', () { + late String documents; + late String roaming; + + setUp(() { + documents = p.join(root.path, 'Documents'); + roaming = p.join(root.path, 'Roaming'); + }); + + /// 在 [programDirectory] 放一個執行檔;[installed] 時加上 `unins000.exe`。 + String install(String programDirectory, {bool installed = false}) { + Directory(programDirectory).createSync(recursive: true); + if (installed) { + File(p.join(programDirectory, 'unins000.exe')).createSync(); + } + return p.join(programDirectory, 'fmp.exe'); + } + + WindowsAppDataDirectory windows( + AppFlavor flavor, { + required String executablePath, + String? applicationSupport, + String? roamingAppData, + }) { + return WindowsAppDataDirectory( + flavor: flavor, + executablePath: executablePath, + roamingAppDataPath: roamingAppData ?? roaming, + applicationSupportPath: () async => + applicationSupport ?? fail('portable must not ask path_provider'), + documentsPath: () async => documents, + ); + } + + test('portable prod uses userdata/ beside the program', () async { + final program = p.join(root.path, 'FMP-portable'); + final directory = await windows( + AppFlavor.prod, + executablePath: install(program), + ).resolve(); + + expect(directory.path, p.join(program, 'userdata')); + expect(directory.existsSync(), isTrue); + }); + + test('portable dev uses userdata-dev/ beside the program', () async { + final program = p.join(root.path, 'FMP-portable'); + final directory = await windows( + AppFlavor.dev, + executablePath: install(program), + ).resolve(); + + expect(directory.path, p.join(program, 'userdata-dev')); + }); + + test('installed uses the application support directory', () async { + final support = p.join(roaming, 'com.personal', 'fmp-dev'); + final directory = await windows( + AppFlavor.dev, + executablePath: install( + p.join(root.path, 'Programs', 'FMP'), + installed: true, + ), + applicationSupport: support, + ).resolve(); + + expect(directory.path, support); + expect(directory.existsSync(), isTrue); + }); + + test(r'dev refuses a directory inside the legacy Documents\FMP', () async { + final executable = install(p.join(documents, 'FMP', 'portable')); + + await expectLater( + windows(AppFlavor.dev, executablePath: executable).resolve(), + throwsA( + isA().having( + (error) => error.legacyLocation, + 'legacyLocation', + p.join(documents, 'FMP'), + ), + ), + ); + expect( + Directory(p.join(documents, 'FMP', 'portable', 'userdata-dev')) + .existsSync(), + isFalse, + ); + }); + + test('dev refuses the legacy application support directory', () async { + final legacy = p.join(roaming, 'com.personal', 'fmp'); + + await expectLater( + windows( + AppFlavor.dev, + executablePath: install( + p.join(root.path, 'Programs', 'FMP'), + installed: true, + ), + applicationSupport: legacy, + ).resolve(), + throwsA(isA()), + ); + }); + + test('prod may use the legacy application support directory', () async { + // 正式版與舊版同一身分(ADR 0008),目錄重疊是預期的;舊資料匯入在 M5。 + final legacy = p.join(roaming, 'com.personal', 'fmp'); + final directory = await windows( + AppFlavor.prod, + executablePath: install( + p.join(root.path, 'Programs', 'FMP'), + installed: true, + ), + applicationSupport: legacy, + ).resolve(); + + expect(directory.path, legacy); + }); + }); + + group('Android', () { + AndroidAppDataDirectory android(AppFlavor flavor, String applicationId) { + return AndroidAppDataDirectory( + flavor: flavor, + applicationSupportPath: () async => + p.join(root.path, 'data', 'user', '0', applicationId, 'files'), + ); + } + + test('dev uses its own sandbox, next to the legacy one', () async { + final directory = await android( + AppFlavor.dev, + 'com.personal.fmp.dev', + ).resolve(); + + expect( + directory.path, + p.join(root.path, 'data', 'user', '0', 'com.personal.fmp.dev', 'files'), + ); + expect(directory.existsSync(), isTrue); + }); + + test('dev refuses the legacy sandbox', () async { + await expectLater( + android(AppFlavor.dev, 'com.personal.fmp').resolve(), + throwsA( + isA().having( + (error) => error.legacyLocation, + 'legacyLocation', + p.join(root.path, 'data', 'user', '0', 'com.personal.fmp'), + ), + ), + ); + }); + + test('prod uses the legacy sandbox (same applicationId)', () async { + final directory = await android( + AppFlavor.prod, + 'com.personal.fmp', + ).resolve(); + + expect( + directory.path, + p.join(root.path, 'data', 'user', '0', 'com.personal.fmp', 'files'), + ); + }); + }); + + group('ensureOutsideLegacyData', () { + test('refuses the legacy location itself and anything below it', () { + final legacy = p.join(root.path, 'FMP'); + + expect( + () => ensureOutsideLegacyData(legacy, [legacy]), + throwsA(isA()), + ); + expect( + () => ensureOutsideLegacyData(p.join(legacy, 'a', 'b'), [legacy]), + throwsA(isA()), + ); + }); + + test('accepts a sibling whose name only starts with the legacy name', () { + final legacy = p.join(root.path, 'FMP'); + + expect( + () => ensureOutsideLegacyData(p.join(root.path, 'FMP-dev'), [legacy]), + returnsNormally, + ); + }); + }); +} diff --git a/app/test/static_rules/material_import_static_rule_test.dart b/app/test/static_rules/material_import_static_rule_test.dart new file mode 100644 index 000000000..e5995618a --- /dev/null +++ b/app/test/static_rules/material_import_static_rule_test.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// `app/` 用獨立套件 `material_ui`,不 import 框架內凍結的設計系統函式庫 +/// (app/AGENTS.md § Material)。M1 PR 3 的 `fmp_lints` 接手後刪掉本檔。 +void main() { + test('lib/ and test/ import no in-framework design library', () { + final offenders = [ + for (final directory in ['lib', 'test']) + for (final file in Directory(directory).listSync(recursive: true)) + if (file is File && file.path.endsWith('.dart')) + for (final uri in frameworkDesignImports(file.readAsStringSync())) + '${p.normalize(file.path)}: $uri', + ]; + expect(offenders, isEmpty); + }); + + group('mutations', () { + test('an import or export of the frozen libraries is reported', () { + expect( + frameworkDesignImports( + "import 'package:flutter/material.dart';\n" + 'import "package:flutter/cupertino.dart" as c;\n' + " export 'package:flutter/material.dart' show Colors;\n", + ), + [ + 'package:flutter/material.dart', + 'package:flutter/cupertino.dart', + 'package:flutter/material.dart', + ], + ); + }); + + test('the standalone packages, other flutter libraries and comments ' + 'are not', () { + expect( + frameworkDesignImports( + "import 'package:material_ui/material_ui.dart';\n" + 'import "package:flutter/widgets.dart";\n' + "// import 'package:flutter/material.dart';\n" + "/// See package:flutter/material.dart for the frozen copy.\n", + ), + isEmpty, + ); + }); + }); +} + +final _directive = RegExp( + r'''^\s*(?:import|export)\s+['"](package:flutter/(?:material|cupertino)\.dart)['"]''', + multiLine: true, +); + +/// [source] 裡 import/export 框架內 Material 或 Cupertino 的 URI。 +List frameworkDesignImports(String source) => [ + for (final match in _directive.allMatches(source)) match.group(1)!, +]; diff --git a/app/test/zero_network_test.dart b/app/test/zero_network_test.dart new file mode 100644 index 000000000..8c9caff65 --- /dev/null +++ b/app/test/zero_network_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +import 'flutter_test_config.dart'; + +/// 零聯網的兩道防線(ADR 0015 §決定 3):`dart_test.yaml` 跳過 `live`, +/// `flutter_test_config.dart` 擋下真實 [HttpClient]。 +void main() { + final blockedByGuard = throwsA( + isA().having( + (error) => error.message, + 'message', + startsWith(NoNetworkHttpOverrides.message), + ), + ); + + test('creating a real HttpClient fails', () { + expect(HttpClient.new, blockedByGuard); + }); + + testWidgets('the guard survives the widget test binding', (tester) async { + expect(HttpClient.new, blockedByGuard); + }); + + test('a test allows the network only inside its own zone', () { + final client = HttpOverrides.runWithHttpOverrides( + HttpClient.new, + _AllowNetwork(), + ); + addTearDown(() => client.close(force: true)); + + expect(client, isA()); + expect(HttpClient.new, blockedByGuard); + }); + + group('dart_test.yaml', () { + test('skips the live tag by default', () { + expect(liveTagSkip(File('dart_test.yaml').readAsStringSync()), isTrue); + }); + + test('a removed or disabled skip is detected', () { + expect(liveTagSkip('tags:\n live:\n'), isFalse); + expect(liveTagSkip('tags:\n live:\n skip: false\n'), isFalse); + expect(liveTagSkip('tags:\n other:\n skip: "x"\n'), isFalse); + }); + + test('layout and wording do not matter', () { + expect(liveTagSkip('tags: {live: {skip: "hits real APIs"}}'), isTrue); + expect(liveTagSkip('tags:\n live:\n skip: true\n'), isTrue); + }); + }); + + // 裸 `flutter test` 跳過這條(第一道);以 `--run-skipped --tags live` 解除 + // 後,它證明第二道仍然擋下未放行的連線。 + test( + 'an unskipped live test is still blocked by the guard', + () => expect(HttpClient.new, blockedByGuard), + tags: 'live', + ); +} + +/// 不覆寫 createHttpClient:沿用 dart:io 建立真實 client。 +final class _AllowNetwork extends HttpOverrides {} + +/// `dart_test.yaml` 是否讓 `live` tag 預設跳過(`skip` 為 true 或理由字串)。 +bool liveTagSkip(String dartTestYaml) { + Object? field(Object? map, String key) => map is YamlMap ? map[key] : null; + final skip = field( + field(field(loadYaml(dartTestYaml), 'tags'), 'live'), + 'skip', + ); + return skip == true || (skip is String && skip.isNotEmpty); +} diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 000000000..a9d2c8910 --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(fmp LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "fmp") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..903f4899d --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..8b6d4680a --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..3ad69c612 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..a6037f59f --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# 原始碼的註解是繁中:MSVC 預設以系統字碼頁讀檔,C4819 在警告即錯誤下會中斷建置。 +target_compile_options(${BINARY_NAME} PRIVATE "$<$:/utf-8>") + +# App 身分依 flavor 決定;main.cpp 與 Runner.rc 只讀這些定義,不寫死身分。 +include("${CMAKE_CURRENT_SOURCE_DIR}/app_identity.cmake") +target_compile_definitions(${BINARY_NAME} PRIVATE "FMP_APP_USER_MODEL_ID=\"${FMP_APP_USER_MODEL_ID}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FMP_SINGLE_INSTANCE_NAME=\"${FMP_SINGLE_INSTANCE_NAME}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FMP_DISPLAY_NAME=\"${FMP_DISPLAY_NAME}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FMP_PRODUCT_NAME=\"${FMP_PRODUCT_NAME}\"") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 000000000..5f3b50ffd --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.personal" "\0" + VALUE "FileDescription", FMP_DISPLAY_NAME "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "fmp" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.personal. All rights reserved." "\0" + VALUE "OriginalFilename", "fmp.exe" "\0" + VALUE "ProductName", FMP_PRODUCT_NAME "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/app_identity.cmake b/app/windows/runner/app_identity.cmake new file mode 100644 index 000000000..505a16011 --- /dev/null +++ b/app/windows/runner/app_identity.cmake @@ -0,0 +1,28 @@ +# Windows 的 App 身分,依 flavor 決定(app/AGENTS.md § App 身分)。 +# +# FLUTTER_APP_FLAVOR 由 flutter 工具寫進 flutter/ephemeral/generated_config.cmake +# (Flutter 3.47 起,https://docs.flutter.dev/deployment/flavors-windows); +# pubspec.yaml 的 default-flavor 讓它永遠有值。prod 與舊版相同(ADR 0008 +# §決定 3,舊版 windows/runner/main.cpp、Runner.rc),dev 每一項都不同(ADR 0015 +# §決定 8)。test/identity/windows_identity_test.dart 以 `cmake -P` 執行本檔核對。 +# +# - FMP_APP_USER_MODEL_ID:SetCurrentProcessExplicitAppUserModelID。 +# - FMP_SINGLE_INSTANCE_NAME:單一實例 mutex 名稱,main.cpp 前面加 `Local\`。 +# - FMP_DISPLAY_NAME:視窗標題與 FileDescription。第二個實例靠「視窗類別+ +# 標題」找第一個實例,所以兩個 flavor 的標題必須不同。 +# - FMP_PRODUCT_NAME:Runner.rc 的 ProductName。path_provider 的 application +# support 目錄是 %APPDATA%\\,dev 靠它分開目錄。 +if(FLUTTER_APP_FLAVOR STREQUAL "prod") + set(FMP_APP_USER_MODEL_ID "com.personal.fmp") + set(FMP_SINGLE_INSTANCE_NAME "FMP_MainInstance") + set(FMP_DISPLAY_NAME "FMP") + set(FMP_PRODUCT_NAME "fmp") +elseif(FLUTTER_APP_FLAVOR STREQUAL "dev") + set(FMP_APP_USER_MODEL_ID "com.personal.fmp.dev") + set(FMP_SINGLE_INSTANCE_NAME "FMP_MainInstance-dev") + set(FMP_DISPLAY_NAME "FMP Dev") + set(FMP_PRODUCT_NAME "fmp-dev") +else() + message(FATAL_ERROR + "Unknown FLUTTER_APP_FLAVOR '${FLUTTER_APP_FLAVOR}': expected dev or prod") +endif() diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..955ee3038 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 000000000..43c2d3531 --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,93 @@ +#include +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +// FMP_* 由 runner/CMakeLists.txt 依 flavor 定義(app_identity.cmake)。 +// 窄字串前接 L"" 會串接成寬字串。 +namespace { + +constexpr wchar_t kMainWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; +constexpr wchar_t kMainWindowTitle[] = L"" FMP_DISPLAY_NAME; +constexpr wchar_t kAppUserModelId[] = L"" FMP_APP_USER_MODEL_ID; +constexpr wchar_t kSingleInstanceMutexName[] = + L"Local\\" FMP_SINGLE_INSTANCE_NAME; + +// 把已在執行的同 flavor 實例帶到前景;照舊版 windows/runner/main.cpp。 +void ActivateExistingInstance() { + HWND existing_window = + ::FindWindowW(kMainWindowClassName, kMainWindowTitle); + if (existing_window == nullptr) { + return; + } + + if (!::IsWindowVisible(existing_window)) { + ::ShowWindow(existing_window, SW_SHOW); + } + if (::IsIconic(existing_window)) { + ::ShowWindow(existing_window, SW_RESTORE); + } + ::SetForegroundWindow(existing_window); +} + +} // namespace + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // 系統媒體控制與工作列分組都以 AUMID 認 App;dev 與 prod 各自一個。 + ::SetCurrentProcessExplicitAppUserModelID(kAppUserModelId); + + // 第二個同 flavor 的實例把第一個帶到前景後結束。 + HANDLE single_instance_mutex = + ::CreateMutexW(nullptr, FALSE, kSingleInstanceMutexName); + if (single_instance_mutex != nullptr && + ::GetLastError() == ERROR_ALREADY_EXISTS) { + ActivateExistingInstance(); + ::CloseHandle(single_instance_mutex); + return EXIT_SUCCESS; + } + + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(kMainWindowTitle, origin, size)) { + if (single_instance_mutex != nullptr) { + ::CloseHandle(single_instance_mutex); + } + ::CoUninitialize(); + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + if (single_instance_mutex != nullptr) { + ::CloseHandle(single_instance_mutex); + } + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 000000000..66a65d1e4 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..c04e20caf Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..153653e8d --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 000000000..3cb714665 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 000000000..60608d0fe --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 000000000..e901dde68 --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/docs/README.md b/docs/README.md index cd7026bd2..ad0dfa33f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ | [建置與發布指南](build-and-release.md) | 操作指南、參考 | 發新版本、調整 CI 或 Release 流程 | CI、產物命名、Release workflow、簽名 secrets、應用內更新資產 | | [疑難排解](troubleshooting.md) | 參考 | 看到像錯誤的建置或 runtime log,或遇到修不掉只能繞過的行為 | 新查明的噪音或已知行為 | | [.trellis/spec/legacy/](../.trellis/spec/legacy/) | 參考 | 用 Trellis 跑舊專案任務,或想知道舊專案某一層的程式碼照什麼模式寫(英文) | 某層的寫法慣例變了;有閘門的規則改在 `lib/AGENTS.md`,spec 只連過去 | +| [.trellis/spec/app/](../.trellis/spec/app/) | 參考 | 用 Trellis 跑 `app/` 任務,或想知道新專案某一層照什麼模式寫(繁中) | 某層的寫法慣例變了;有閘門的規則改在 `app/AGENTS.md`,spec 只連過去 | | [adr/](adr/) | 說明 | 想知道某個跨模組決定「當初為什麼這樣選」 | 新的跨模組決策(決定、理由、被否決的方案) | | [verify-legacy-on-device skill](../.claude/skills/verify-legacy-on-device/SKILL.md) | 操作指南 | 舊專案緊急修正改了使用者可見行為,要做強制的實機驗證 | 模擬器啟動方式、驗證流程、裝置端限制 | | [audit/](audit/) | 快照(凍結) | 對照重寫前的現況、功能勾選與效能基準 | 只允許核查更正;切換 PR 刪除(ADR 0008、0026) | diff --git a/docs/adr/0009-platform-layer-with-declared-capabilities.md b/docs/adr/0009-platform-layer-with-declared-capabilities.md index bb1bc935f..d2fb82b74 100644 --- a/docs/adr/0009-platform-layer-with-declared-capabilities.md +++ b/docs/adr/0009-platform-layer-with-declared-capabilities.md @@ -74,6 +74,7 @@ Linux、macOS 加入時要回頭改介面。 - 壞的:每個能力多一層介面;Linux/macOS/iOS 在其 child task 完成前只有核心功能。 - 之後要注意:登入 WebView 在 Linux 的選型、Android 下載儲存、音訊後端、Linux 打包與各平台更新機制分別在 網路與帳號、下載與權限、播放核心、發版與更新的 ADR 決定,並更新本 ADR 的套件表引用。 +- 更正(2026-09-29):§決定 7 的 Portable 版資料夾改名為程式旁的 `userdata/`(開發版 `userdata-dev/`)。Flutter 的 Windows 產物本身在程式旁放 `data/`(`flutter_assets`、`app.so`),同名會讓使用者資料混進程式目錄,更新時有被覆蓋的風險;擁有者 2026-09-29 選定(M1 PR 2)。 ## 如何確認 diff --git a/docs/adr/0015-testing-gates-and-dev-environment.md b/docs/adr/0015-testing-gates-and-dev-environment.md index 53ddcf910..339aa7b42 100644 --- a/docs/adr/0015-testing-gates-and-dev-environment.md +++ b/docs/adr/0015-testing-gates-and-dev-environment.md @@ -87,6 +87,7 @@ Flutter 官方 flavor 與 `default-flavor`;`dorny/paths-filter` 的 monorepo - 之後要注意:`flutter analyze` 的插件診斷 bug 修好後可以拿掉 `dart analyze` 的重複步驟,但接線哨兵保留; 播放核心、歌詞、背景任務、UI、發版各自決定 `design.md` §6 標給它們的規則;`riverpod_lint` 是否已遷移到新插件系統在落地時查證。 更正(2026-09-29):上面「`flutter analyze` 不顯示插件診斷」引用的 flutter/flutter#193203 已於 2026-09-23 以重複關閉,正確 issue 是仍為 open 的 flutter/flutter#187999。 + 更正(2026-09-29):§決定 3 的「以 preset 解除」在 `flutter test` 做不到,它不接受 `--preset`(flutter_tools `lib/src/test/runner.dart`);解除方式改為 `flutter test --run-skipped --tags live`(M1 PR 2)。 ## 如何確認 diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 9cf701889..9c6165be6 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -181,14 +181,24 @@ git push origin v1.2.0 ### CI 流程 -一般驗證由 `.github/workflows/ci.yml` 負責。**沒有 path filter** —— 純文檔的 -commit 一樣跑滿: +一般驗證由 `.github/workflows/ci.yml` 負責。`changes` job 依變動路徑分流兩個專案 +(ADR 0015 §決定 9):`app/**` 與 `.github/**` 跑新專案的 `app`,`app/` 以外有任何 +變動(含純文件)跑舊專案的三個 job,手動觸發全跑。最後的 `CI Result` 彙總所有 job: +被跳過的算通過,失敗或取消的算失敗。 ```text pull_request / main push / workflow_dispatch │ ▼ CI + │ + ├─ changes (ubuntu) ── dorny/paths-filter:app/legacy + │ + ├─ app (ubuntu,app/ 內,Flutter 3.47.5) + │ ├─ flutter pub get + │ ├─ dart format --output=none --set-exit-if-changed . + │ ├─ flutter analyze + │ └─ flutter test │ ├─ validate (ubuntu) │ ├─ flutter pub get @@ -201,8 +211,10 @@ CI ├─ build-android (ubuntu) │ └─ flutter build apk --release --target-platform android-arm64 │ - └─ build-windows (windows-2022) - └─ flutter build windows --release + ├─ build-windows (windows-2022) + │ └─ flutter build windows --release + │ + └─ CI Result(always) ``` `validate` 會執行程式碼產生、格式檢查、analyzer 與測試;兩個 build job 只作為跨平臺 release build 煙霧測試,不建立 GitHub Release。`*.g.dart` 等產生檔不進版本控制(見 `.gitignore`),所以本流程不對「產生檔已提交」做檢查——那類檢查在 git 從未追蹤這些檔案的情況下永遠會通過,無法真正偵測任何問題。圖示資產由維護者在本機執行 `dart run flutter_launcher_icons` 後提交,CI 不在每次驗證時重產圖示。 diff --git a/orca.yaml b/orca.yaml index 8f1809573..f210a47df 100644 --- a/orca.yaml +++ b/orca.yaml @@ -15,7 +15,9 @@ scripts: # 每行只放一個 cmd 與 bash 都成立的指令:Windows 上 Orca 用 cmd 把每行包成 # `call …` 並在失敗時中止,其他平台用 bash `set -e`。不加 `#!`,cmd 會拒跑。 # 改動這段會讓 Orca 的信任失效,要在 GUI 重新選 Always trust。 + # app/ 用 `--directory`,不用 `cd`:cd 會留到後面幾行。 setup: | flutter pub get dart run build_runner build dart run slang + flutter pub get --directory app