Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/verify-on-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ description: >-
- `adb` 在 `PATH`;`emulator.exe` 不在,用 `$ANDROID_HOME/emulator/emulator.exe`。
- 只用 dev flavor(`com.personal.fmp.dev`、視窗標題 `FMP Dev`)。**不要動模擬器上的舊版
`com.personal.fmp`,也不要把 prod APK 裝上去**:它放著舊版的測試資料。
- `app/` 沒有 slang,drift 的 `*.g.dart` 已提交:建置前不必跑 codegen(改了 table 才跑
`dart run build_runner build`)。
- drift 與 slang 的 `*.g.dart` 已提交:建置前不必跑 codegen(改了 table 才跑
`dart run build_runner build --delete-conflicting-outputs`,改了翻譯才跑 `dart run slang`)。
- Git Bash 會改寫 `/data/...` 這類路徑:`adb shell` 前加 `MSYS_NO_PATHCONV=1`。Python 單行指令前加
`PYTHONIOENCODING=utf-8`。
- 有 Orca 時,`orca skills get computer-use`/`orca-cli` 取得與版本相符的指令參考;不靠記憶。
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,17 +227,19 @@ jobs:
- name: Check formatting
run: dart format --output=none --set-exit-if-changed .

# drift 產生的 *.g.dart 有提交(app/AGENTS.md § 資料層)。重跑 codegen 後
# app/ 有任何變動或新檔,就是改了 table 卻沒重跑 build_runner。
# drift 與 slang 產生的 *.g.dart 有提交(app/AGENTS.md § 資料層、§ 介面)。
# 重跑兩者後 app/ 有任何變動或新檔,就是改了 table 或翻譯檔卻沒重新產生。
# slang 用自己的 CLI(設定在 slang.yaml),不走 build_runner。
# 路徑用 `.`:這個 job 的工作目錄已經是 app/。
- name: Check generated code is up to date
run: |
dart run build_runner build
dart run build_runner build --delete-conflicting-outputs
dart run slang
changes="$(git status --porcelain -- .)"
if [ -n "$changes" ]; then
echo "$changes"
git diff -- .
echo "Generated code is stale: run 'dart run build_runner build' in app/ and commit the result."
echo "Generated code is stale: run 'dart run build_runner build --delete-conflicting-outputs' and 'dart run slang' in app/ and commit the result."
exit 1
fi

Expand Down
4 changes: 2 additions & 2 deletions .trellis/spec/app/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ https://drift.simonbinder.eu/migrations/step_by_step/、
https://drift.simonbinder.eu/migrations/tests/。

1. 改 `tables.dart`,`AppDatabase.schemaVersion` 加一。
2. `dart run build_runner build`。
2. `dart run build_runner build --delete-conflicting-outputs`。
3. `dart run drift_dev make-migrations`。它會:
- 存 `drift_schemas/app_database/drift_schema_v<N>.json`;
- 產生 `lib/data/database/app_database.steps.dart`(`stepByStep`);
Expand Down Expand Up @@ -96,6 +96,6 @@ https://drift.simonbinder.eu/migrations/tests/。

## Quality Check

- `dart run build_runner build` 後 `git status` 沒有變動(CI 同一步)。
- `dart run build_runner build --delete-conflicting-outputs` 後 `git status` 沒有變動(CI 同一步)。
- `schema_test.dart` 綠;改過 schema 就有新快照與第 5 步的三種測試。
- `lib/data/` 以外沒有 drift 型別出現在 import 或公開 API。
23 changes: 17 additions & 6 deletions .trellis/spec/app/errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ throw RateLimited(
throw AppError.wrap(error, stackTrace, pluginId: pluginId);
}

// 使用者動作的呼叫端:寫錯誤歷史,再交給呈現層(Toaster 在 PR 12)。
// 使用者動作的呼叫端:交給 Toaster,它先寫錯誤歷史再顯示。
} on AppError catch (error) {
log.report('Search failed', error, tag: 'search');
ref.read(toasterProvider).error(error, operation: 'Search failed', tag: 'search');
}

// 背景工作:只寫錯誤歷史,不跳提示,畫面的狀態自己更新。
} on AppError catch (error) {
log.report('Sync failed', error, tag: 'sync');
}
```

Expand All @@ -53,7 +58,8 @@ throw RateLimited(
- 限流帶 `Retry-After` 時用 `parseRetryAfter` 填 `retryAfter`;`now` 用收到回應的時間。
- 預設 `retryable` 不合用時才覆寫(例如某個 5xx 業務碼其實可重試)。覆寫只決定「錯誤
可不可以重試」,請求是否冪等由網路層另外判斷。
- `messageArgs` 只放數字、enum 之類的值;伺服器的訊息原文放 `cause`,只進 log。
- `messageArgs` 的型別只收 `ErrorMessageArg` → 整數;伺服器的訊息原文放 `cause`,只進
log。
- 每一列對應表配一個以錄下的錯誤回應 fixture 寫的契約測試,斷言轉出的子類
(ADR 0013 §如何確認)。

Expand All @@ -74,9 +80,14 @@ throw RateLimited(
## 加一個 i18n key 或原因

- `ErrorMessageKey` 一個值對應 ADR 0013 §決定 5 類別表的一列,名稱就是 slang 的 key
(PR 12 起在 `errors.` 之下);加值要同時加翻譯,改名等於改翻譯檔的 key。
- `UnavailableReason` 加值時,同時加曲目上標示原因的翻譯(PR 12 起)。`report` 以
`reason.name` 寫進 log,改名就是改 log 的值。
(`lib/i18n/*.i18n.json` 的 `errors.` 之下);加值要同時在三個 JSON 加翻譯,並在
`lib/ui/errors/error_message.dart` 的 `switch` 補一列(編譯器會指出)。改名等於改翻譯檔的
key。
- `UnavailableReason` 加值時,同時在 `errors.unavailableReasons.` 之下加翻譯,並補
`unavailableReasonText`。`report` 以 `reason.name` 寫進 log,改名就是改 log 的值。
- 訊息要帶數值時加一個 `ErrorMessageArg`(值只能是整數),翻譯寫 `{參數}`,在
`errorMessage` 裡讀 `error.messageArgs`。音源名稱不是參數:呈現層以 `pluginId` 查。
- 怎麼加 i18n key 的其他細節:`.trellis/spec/app/ui/index.md` § 字串。

## 加欄位

Expand Down
14 changes: 9 additions & 5 deletions .trellis/spec/app/settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
這個 `map` 裡套用(`resolveAppearance` 這類純函式),不寫回資料庫。
- 對外的值型別同時帶「生效值」與 `stored`(使用者設定過的值),設定頁用 `stored` 的
`null` 顯示「跟隨系統/預設」。
- 設定方法一個欄位一個,只寫那個欄位(`repository.write(themeMode: ...)`)。
- 設定方法一個欄位一個,只寫那個欄位(`repository.write(themeMode: ...)`)。參數可空,
`null` 是「清回沒設定過」,走 `repository.clear(themeMode: true)`:`write` 的 `null`
表示「沒給、不動」,兩者不能共用一個方法。

## 加一個設定欄位

Expand All @@ -28,14 +30,16 @@
`schemaVersion` 加一、`build_runner`、`make-migrations` 存快照、`onUpgrade`、三種
migration 測試。新欄位在舊資料上是 `NULL`,也就是「沒設定過」,migration 不填值。
3. **repository**:值型別加欄位(含 `==`、`hashCode`、`toString`),`write` 加一個
可省略的參數,用 `Value.absentIfNull`。
4. **Notifier**:生效值型別加欄位,在解析函式裡寫 `stored.x ?? 預設`;加一個 setter。
可省略的參數,用 `Value.absentIfNull`;`clear` 加一個 `bool` 參數,寫 `Value(null)`。
4. **Notifier**:生效值型別加欄位,在解析函式裡寫 `stored.x ?? 預設`;加一個 setter
(`null` 呼叫 `clear`)。
5. **預設值**:只寫在解析函式的呼叫處。之後改預設只改這一處,不需要 migration。
6. **測試**:
- repository:讀回寫入的值、部分寫入不動其他欄位、`stored format` 釘住字面值;
- Notifier:沒設定時讀到預設;設定後讀到使用者值;
- 改預設:同一份 `stored` 用兩組預設解析,使用者值不變、未設定的跟著新預設;
- 只寫改動的欄位:寫一個欄位後直接查表,其他欄位仍是 `NULL`。
- 只寫改動的欄位:寫一個欄位後直接查表,其他欄位仍是 `NULL`;
- 清回未設定:`clear` 後直接查表是 `NULL`,其他欄位不動,讀到的回到預設。

## 測試寫法

Expand All @@ -52,4 +56,4 @@

- 表的新欄位是 `nullable()`,migration 沒有填預設值。
- 預設值沒有出現在 `lib/data/`。
- 上面第 6 步的四種測試都在。
- 上面第 6 步的五種測試都在。
123 changes: 123 additions & 0 deletions .trellis/spec/app/ui/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 介面(`app/lib/ui/`、`app/lib/i18n/`)

寫畫面、加字串、跳提示時適用。規則(token、字串來源、提示入口)與閘門見
`app/AGENTS.md` § 介面;為什麼是這些選擇,見 ADR 0023、ADR 0024 與
`.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md`。這裡只寫怎麼做。

## 目錄

```
lib/i18n/
zh-TW.i18n.json # base locale;新字串先寫這裡
zh-CN.i18n.json
en.i18n.json
strings*.g.dart # slang 產生,提交
lib/ui/
theme/ # AppTokens、AppLayout、buildAppTheme;fmp_design_tokens 豁免
layout/ # WindowClass、WindowClassScope
i18n/ui_locale.dart # LocaleSetting → Flutter locale/slang/字型;translationsProvider
errors/ # AppError → 訊息
toast/ # Toaster、ToastHost;fmp_toast_entry 的允許目錄
settings/ # 設定頁的控制項
lib/app/app_material.dart # 三個 App 根元件共用的 MaterialApp 設定
```

## 間距、圓角、顏色

```dart
final tokens = AppTokens.of(context);
Padding(padding: EdgeInsets.all(tokens.spacing.x4)); // 16
SizedBox(height: tokens.spacing.x2); // 8
BorderRadius.circular(tokens.radius.medium); // 12
Container(color: tokens.success.container); // 語意色
Text('…', style: Theme.of(context).textTheme.titleMedium); // 字級只用角色
```

- 間距只有 ADR 0024 的九個值:`x1 x2 x3 x4 x5 x6 x8 x10 x12`(×4dp)。要的值不在裡面時先
想是不是元件的固定尺寸;是就加進 `AppLayout`,不是就改用最接近的 token。
- 顏色:`Theme.of(context).colorScheme` 的角色,或 `AppTokens` 的 `success`/`warning`。
語意色成對用(`container` 配 `onContainer`、`color` 配 `onColor`),對比度才有保證。
- 元件的固定尺寸(封面上限、面板寬度)放 `AppLayout`,隨元件出現時加。
- `lib/ui/theme/` 可以寫數字與 `Color(0x…)`;其他地方寫了 `fmp_design_tokens` 會報。真的
不是設計值的(宿主的透明底)以 `// ignore: fmp_lints/fmp_design_tokens — 理由` 標出。

## 寬度

```dart
switch (WindowClass.of(context)) {
WindowClass.compact || WindowClass.medium => …,
WindowClass.expanded || WindowClass.large || WindowClass.extraLarge => …,
}
```

- `WindowClass.of` 讀最近的 `WindowClassScope`;App 根放了一個(量整個視窗),外殼(12b)
在內容區再放一個,頁面讀到的是內容區的等級。
- 只在等級改變時重建。要精確寬度的版面仍用 `LayoutBuilder`。

## 字串

加一條字串:

1. 三個 JSON 都加同一個 key,先寫繁中。參數寫 `{name}`,三個語言的參數要一樣。
2. `dart run slang`(設定在 `slang.yaml`),提交 `lib/i18n/*.g.dart`。
3. widget 裡 `final t = ref.watch(translationsProvider);`,`t.section.key` 或
`t.section.key(name: …)`。沒有 slang 的全域 `t`、`context.t`。

- 缺某個語言也編譯得過(`fallback_strategy: base_locale`,退回繁中),擋住漏翻的是
`test/i18n/translations_test.dart`。
- 簡中寫大陸用語(登录、网络、粘贴),不是繁中逐字轉換。英文句子裡的參數放在句中,
音源名稱沒有時代入的是小寫的 `the source`。
- 不翻的字(語言選單上各語言的名稱)不放 JSON,寫在程式碼並註明理由(`localeEndonym`)。
- 漢字的繁簡字形看文字樣式的 `locale`:`buildAppTheme` 把 `textLocaleOf` 放進每個
`TextTheme` 角色(英文介面是繁中),Android 這類不指名字型的平台只看它。顯示中文但不跟
介面語言的文字(語言名稱、之後的曲名)在**樣式**上給 locale
(`style: TextStyle(locale: …)`);`Text.locale` 會被主題樣式的 locale 蓋過,沒有作用。

## 錯誤訊息

- `errorMessage(t, error, sourceName: …)`(`lib/ui/errors/error_message.dart`)以
exhaustive `switch` 把 `ErrorMessageKey` 對到 `t.errors.<同名>`。加 key 時編譯器會指出這裡;
JSON 三個都要加(測試逐一檢查每個 `ErrorMessageKey`、`UnavailableReason`)。
- 音源名稱由呈現層以 `pluginId` 查 manifest 的 `name`;查不到(未安裝)時用 `pluginId`
本身(manifest 驗過格式:小寫英數與 `-`),錯誤沒有 `pluginId` 才用 `errors.unknownSource`。

## 提示

```dart
final toaster = ref.read(toasterProvider);
toaster.success(t.library.added);
toaster.info(t.share.copied, action: ToastAction(label: t.common.undo, onPressed: undo));
try {
await search(query);
} on AppError catch (error) {
toaster.error(error, operation: 'Search failed', tag: 'search');
}
```

- 只給使用者動作的回饋。背景工作不呼叫 `Toaster`:`log.report` 後更新畫面上的狀態。
- `error` 自己呼叫 `log.report`,呼叫端不要再 report 一次。
- 每則最多一個動作;帶動作的也照時長消失(成功與資訊 4 秒、警告與錯誤 6 秒)。
- 去重 5 秒:訊息以「種類+文字」,錯誤以「類別+音源」。
- 外殼(12b)以 `ref.read(toastBottomInsetProvider.notifier).set(高度)` 發佈從視窗底邊算起
被佔住的高度(含安全區);全螢幕頁不發佈時要設回 0。
- M1 沒有「詳細」與「回報」(ADR 0023 §決定 4 延到 M3,和 Debug 頁的錯誤歷史一起做)。

## 測試

- 畫面測試用 `buildAppTheme` 的主題;有提示的包 `ToastHost`,以
`toasterProvider.overrideWithValue(Toaster(...))` 注入(例子:
`test/ui/toast/toast_host_test.dart`)。
- 時間:`Toaster` 讀 `clock.now()`,單元測試用 `fakeAsync`,widget 測試的
`tester.pump(duration)` 也會推進它。
- 新畫面加進 guideline 測試(`test/ui/guidelines_test.dart` 的寫法):淺色、深色各跑
`labeledTapTargetGuideline`、`androidTapTargetGuideline`、`textContrastGuideline`,
`tester.ensureSemantics()` 要開。
- 語言:`TestWidgetsFlutterBinding.instance.platformDispatcher.localesTestValue` 設系統語言,
`addTearDown(clearLocalesTestValue)`;測試預設是 `en_US`。

## Quality Check

- `lib/ui/`(theme 以外)沒有數字字面值的間距、圓角、字級與 `Colors.*`:`dart analyze`
乾淨。
- 新字串三個 JSON 都有、產生檔已重跑並提交。
- 新畫面在淺色、深色都過 guideline 測試。
6 changes: 5 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
- **PR 10 完成**(#187,子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-playback-core/`):播放核心、兩個後端、前瞻交接。dev 入口 `--fmp-dev-playback`(可加 `=<曲目鍵>`);實機數字在該子任務與 #187 描述。
- **YouTube.js 探針完成**:通過(VISIONOS client),M3 的 YouTube 走插件;程式碼在分支 `probe/youtubejs`(已 push,不合併),結論在 `.trellis/tasks/archive/2026-09/09-30-youtubejs-probe/`,ADR 0014 §決定 10 已補。Android 只驗到音訊系統層(模擬器 `-no-audio`)。
- **PR 11 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-verify-on-device/`):`.claude/skills/verify-on-device/`;實機驗證一律照它做,回報含平台與模式。Windows 腳本以視窗標題 `FMP Dev` 比對,避免點到同名 `fmp.exe` 的舊版。
- **下一步**:12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。
- **PR 12a 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-ui-foundation/`):主題 token、`WindowClass`、slang 三語言、`Toaster`/`ToastHost`、`messageArgs` 收窄、外觀可清回跟隨系統。實機:Windows 繁中/English 正黑體、简中雅黑;Android 英文介面原本落到簡中字形,已改為主題文字樣式帶 `zh-Hant` 並複驗。
- **下一步**:12b(`.trellis/tasks/09-30-app-shell`,prd 已核准) → 13 五平台建置與發版 workflow → 里程碑驗收。
- **擁有者決定**:1–8 都在父任務 `prd.md`「擁有者的決定」。9a、9b 期間新增了三項:
- 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest;
- 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟;
Expand Down Expand Up @@ -209,6 +210,7 @@ PR 10 留下的後續:

- [ ] `app/android/app/src/main/AndroidManifest.xml` 沒有 `INTERNET` 權限(只有 `debug/`、`profile/` 有),release 建置連不了網路;PR 13 處理。
- [ ] 開不起來的串流在換過候選後對應 `Unsupported`,ADR 0013 會顯示成「視為 bug」的通用訊息;CDN 403 落到這裡不貼切,PR 12 做提示時再看。
- [ ] 12a 審查留給 12b:`ToastHost` 多包的 `Overlay` 成了 root overlay,搜尋框的文字選取工具列、放大鏡要實機確認;鍵盤彈出時提示位置(`viewInsets` 減 `viewPadding`)沒有測試;`Toaster` 的 stream 同步發送,在 build 階段呼叫會出錯(接 `ref.listen` 時留意)。
- [ ] 前瞻開不起來時兩個後端的行為沒有契約案例(Android 會被當成目前這首中斷;Windows 可能卡在 Playing);前瞻解析比目前這首播完還慢時會多解析一次。M2 補契約案例。
- [ ] 被取代的 `resolveStream` 只丟結果、不取消網路工作(`SourcePlugin` 沒有取消參數)。
- [ ] `.trellis/spec/app/playback/index.md` 的「實機驗證」段與 `verify-on-device` skill 的建置、安裝、`am start` 步驟重複;改成指向 skill(PR 12 動到播放時順手)。
Expand Down Expand Up @@ -243,6 +245,8 @@ PR 10 留下的後續:

### 12. UI(擁有者決定 2)

2026-09-30 擁有者核准:拆成 **12a** UI 基礎(`.trellis/tasks/09-30-ui-foundation`)與 **12b** 外殼與頁面(`.trellis/tasks/09-30-app-shell`);ADR 0023 §決定 4 的 `ErrorReport`、詳細頁、「回報」延到 M3(與 Debug 頁的錯誤歷史共用);12b 移除身分頁與 `--fmp-dev-playback`,保留 `--fmp-dev-plugin`。

- [ ] 主題與字串:
- `AppTokens`、`AppLayout`;
- `WindowClass`;
Expand Down
4 changes: 3 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"09-30-redaction-cdn-rules",
"09-30-playback-core",
"09-30-youtubejs-probe",
"09-30-verify-on-device"
"09-30-verify-on-device",
"09-30-ui-foundation",
"09-30-app-shell"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
Empty file.
Empty file.
Loading
Loading