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
45 changes: 45 additions & 0 deletions .trellis/spec/app/platform/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 平台層(`app/lib/platform/`)

加或改平台能力時適用。規則(只含已實作的能力、平台判斷只在組裝點)與閘門見
`app/AGENTS.md` § 平台層;為什麼這樣分,見 ADR 0009。這裡只寫怎麼做。

## 目錄

```
lib/platform/
platform.dart # 組裝點 AppPlatform:唯一判斷平台的地方
platform_capabilities.dart # 能力宣告 PlatformCapabilities
<能力>/
<能力>.dart # 介面(或值型別)與跨平台共用的邏輯
<能力>_<平台>.dart # 各平台實作;平台套件只在這裡 import
```

現有的例子:`app_data_directory/`(介面+兩個實作)、`fonts/`(值型別+各平台的常數)。

## 加一個能力

1. **介面**:`<能力>/<能力>.dart`。有行為的用 `abstract interface class`;只是資料的
(像字型清單)用 `@immutable` 的值型別。介面不 import 平台套件,也不判斷平台。
2. **實作**:只替已經要在實機驗證的平台寫 `<能力>_<平台>.dart`。系統值(路徑、環境變數、
path_provider 的呼叫)從建構子注入,實作本身不讀 `Platform`,才能在暫存目錄上測。
3. **宣告**:在 `PlatformCapabilities` 加欄位,連同 `none` 一起改。欄位表達「有沒有」或
平台給的值;不為還沒實作的能力預留。
4. **組裝**:`platform.dart` 的 `switch` 在各平台分支建立實作並填宣告;讀
`Platform.resolvedExecutable`、`Platform.environment` 這類值也在這裡。有實作的能力在
`AppPlatform` 加一個欄位,並擴充建構子的 `assert`,讓宣告與實作對得上。
5. **呼叫端**:UI 看 `capabilities` 決定是否顯示入口;服務拿 `AppPlatform` 上的實作。
`lib/platform/` 以外不判斷平台。

## 測試

- `test/platform/platform_test.dart`:`AppPlatform.assemble(TargetPlatform.x, …)` 注入平台值,
逐平台斷言新欄位與實作型別;未驗證平台的案例斷言它仍是「沒有」。
- `test/platform/<能力>_test.dart`:直接建構各平台實作、注入路徑與 callback,在
`Directory.systemTemp` 下跑,不讀真實 `Platform`(例子:`app_data_directory_test.dart`)。
- 使用者看得到的能力另外在 Android 模擬器與 Windows 實機驗(`app/AGENTS.md` § 實機驗證)。

## Quality Check

- `AppPlatform` 以外沒有新的平台判斷;`dart analyze --fatal-infos` 乾淨。
- 未驗證平台(Linux、macOS、iOS)沒有新的實作檔。
- 新欄位在 `platform_test.dart` 每個平台都有一個斷言。
1 change: 1 addition & 0 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
- [ ] `Toaster`、`ToastHost`。
- [ ] 外殼(兩個導覽項)、搜尋頁、設定頁的外觀組、播放列。
- [ ] 快捷鍵與 F6 三區。
- [ ] 字形:`MaterialApp.locale` 帶介面語言(`zh-Hant-TW` 等帶 script 的形式)。Android 不指名字型,英文介面時歌名等漢字可能落到簡中字形(AOSP `fonts.xml` 的 `zh-Hans` 在前,PR 4 推論、未實測):實測後決定是否在漢字文字上指定 `zh-Hant`。
- 測試:
- 三語言 key 集合相同;
- Toast 去重、取代與時長;
Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"children": [
"09-29-split-agent-instructions",
"09-29-app-skeleton",
"09-29-fmp-lints"
"09-29-fmp-lints",
"09-29-platform-layer"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout and technical choices"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Platform layer rules"}
{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Font fallback lists"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules the platform layer must satisfy"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout and technical choices"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Platform layer rules"}
{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Font fallback lists"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules the platform layer must satisfy"}
60 changes: 60 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-platform-layer/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 平台層與能力宣告(M1 PR 4)

父任務:`../09-28-m1-skeleton-tracer`(design §2;implement「4.」)。

依據:
- ADR 0009 §決定 1–4:每能力一目錄、不可變的能力宣告、平台判斷只在平台層、不寫空實作;
- ADR 0009 §決定 7:目錄規則,Windows 免安裝版改 `userdata/`;
- ADR 0024 §決定 2:字型 fallback。

## 範圍原則

`PlatformCapabilities` 只放 M1 用到的能力,不預先宣告托盤、快捷鍵、歌詞視窗等欄位;引入該能力的里程碑再加欄位與實作(ADR 0009 §決定 4 的「不寫空實作」)。播放後端與可播格式在 PR 10 加。

## 做什麼

1. **`lib/platform/platform_capabilities.dart`**:不可變的 `PlatformCapabilities`,M1 的欄位:
- `dataDirectory`:有沒有 App 資料目錄的實作;
- `singleInstance`:Windows 為真,由原生 runner 實作(PR 2),Dart 端只宣告;
- `fontFallback`:依語言排序的字型清單,見第 3 點。

欄位型別與命名照 ADR 0009 §決定 2。
2. **平台組裝**:`lib/platform/platform.dart` 在啟動時依平台組出能力宣告與各能力的實作,只有這裡判斷平台。
- Android 與 Windows 有實作;
- Linux、macOS、iOS 宣告全部為「沒有」,沒有實作檔。
- PR 2 的 `appDataDirectoryFor` 併進這個組裝點,`app_data_directory/` 的結構維持「每能力一目錄」。
3. **字型 fallback**(ADR 0024 §決定 2):`lib/platform/fonts/`:

| 語言 | Windows | 其他平台 |
|---|---|---|
| zh-TW | `Microsoft JhengHei UI`、`Microsoft JhengHei` | `Noto Sans TC` |
| zh-CN | `Microsoft YaHei UI`、`Microsoft YaHei` | `Noto Sans SC` |
| en | 繁中清單在前,簡中在後 | 同左 |

- Android 上 Flutter 怎麼選 CJK 字形:`fontFamilyFallback` 能不能用系統字型名,還是要靠 `Locale`。先查官方文件與 Flutter issue,把結論與來源記在 `research/notes.md`。
- 如果 Android 上寫 `Noto Sans TC` 沒有效果,就照官方做法,靠 `Locale` 讓引擎選字形,並在 ADR 0024 補一句更正。
- 主題怎麼套用字型在 PR 12;本 PR 只提供清單與取得方式。
4. **不支援的平台**:`main()` 在 `dataDirectory` 為「沒有」時不啟動資料層,顯示一個「此平台尚未支援」的最小畫面。
- 字串先寫死繁中,註解說明 slang 在 PR 12 接上後改掉。
- Linux、macOS、iOS 只要能編譯、能開出這個畫面就好。
5. **測試**:
- 各平台的能力宣告:以注入的平台值組裝,不讀真實 `Platform`;
- 三種語言在 Windows 與其他平台的字型清單;
- 不支援的平台顯示那個畫面(widget test);
- PR 2 的資料目錄測試維持通過。
6. **文件**:
- `app/AGENTS.md` 平台段寫三件事:
- 能力宣告只含已實作的能力;
- 新能力連同實作一起加;
- 平台判斷只在組裝點(`fmp_platform_checks` 守)。
- `.trellis/spec/app/platform/index.md`(繁中)寫怎麼加一個能力:目錄、介面、各平台實作、宣告欄位、測試。

## 驗收

- [ ] `app/`:
- format 通過;
- `dart analyze --fatal-infos`、`flutter analyze` 零問題;
- `flutter test` 全綠;
- 哨兵通過。
- [ ] Windows dev 版建置並開啟,畫面與 PR 2 相同(主對話實機)。
- [ ] 平台判斷只出現在 `lib/platform/`(lint 已守)。
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Flutter 怎麼選 CJK 字形(Android、Windows)

查證日期 2026-09-29,對照 Flutter 3.47.5(engine `af7e796e16`)與 flutter/flutter、google/skia 的 main。
這個環境沒有 context7/tavily,來源都是直接抓官方原始碼與文件。

## 結論

| 平台 | `fontFamilyFallback` 寫系統字型名 | 依 locale 選字形 | 本 PR 的做法 |
|---|---|---|---|
| Windows | 有效(DirectWrite 對得到系統字型名) | 只影響沒被指名字型涵蓋的字元 | 照 ADR 0024 指名正黑體/雅黑(`fonts_windows.dart`) |
| Android | **無效**:`Noto Sans TC`/`SC` 在系統裡不是有名稱的 family | 有效:模擬器實測 `zh-Hant` 的 locale 拿到繁中字形(見「實機結果」) | 不指名(`FontFallback.none`,`fonts_android.dart`);ADR 0024 補更正 |

## 引擎的路徑(原始碼)

1. 文字的 locale:`RichText.createRenderObject` 傳 `locale ?? Localizations.maybeLocaleOf(context)` 給
`RenderParagraph`(`packages/flutter/lib/src/widgets/basic.dart`)。`TextStyle.locale` 的 dartdoc:
「The locale used to select region-specific glyphs … Typically … defined by … `Localizations.localeOf(context)`」
(`packages/flutter/lib/src/painting/text_style.dart`)。
2. dart:ui 把 locale 編成字串:`_encodeLocale(Locale? locale) => locale?.toString() ?? ''`
(`engine/src/flutter/lib/ui/text.dart`),`Locale.toString()` 是 `_rawToString('_')`,也就是
**底線**格式 `zh_Hant_TW`(`lib/ui/platform_dispatcher.dart`;`toLanguageTag()` 才是 `-`)。
3. engine 原樣傳給 SkParagraph:`paragraph_builder.cc` 的 `style.locale = locale`,
`paragraph_builder_skia.cc` 的 `setLocale(SkString(txt.locale.c_str()))`。
4. SkParagraph 缺字時 `FontCollection::defaultFallback(unicode, families, style, locale)` 呼叫
`matchFamilyStyleCharacter(familyName, style, {locale}, …)`(`skia/modules/skparagraph/src/FontCollection.cpp`)。
5. 各平台的字型管理器(`engine/src/flutter/txt/src/txt/platform_*.cc`):
- Android:`SkFontMgr_New_Android`,預設 family `sans-serif`;
- Windows:`SkFontMgr_New_DirectWrite`,預設 family `Segoe UI`、`Arial`。

## Android

- 系統字型設定 `data/fonts/fonts.xml`(AOSP,android14-release 與 main 都一樣):Noto Sans CJK 是
`<family lang="zh-Hans">`、`<family lang="zh-Hant,zh-Bopo">`、`<family lang="ja">`、`<family lang="ko">`,
**沒有 `name`**,指向同一個 `NotoSansCJK-Regular.ttc` 的不同 index;`zh-Hans` 排在 `zh-Hant` 前。
來源:https://github.com/aosp-mirror/platform_frameworks_base/blob/android14-release/data/fonts/fonts.xml
- Skia `src/ports/SkFontMgr_android.cpp`:
- 沒有名稱的 fallback family 只會有自動產生的名稱 `"%.2x##fallback"`(`addFamily`);
- `onMatchFamily(name)` 只比對 `fNameToFamilyMap`/`fFallbackNameToFamilyMap` 的名稱。
- 所以 `fontFamilyFallback: ['Noto Sans TC']` 找不到字型,會被略過,**寫了等於沒寫**。
- 同一檔的 `onMatchFamilyStyleCharacter`(下方「原先的推論」的依據,實機結果與它不符):以 locale 選 fallback family 時,條件是字型的 `lang`
`startsWith(要求的 tag)`;找不到就 `SkLanguage::getParent()`,以最右邊的 **`-`** 截掉一段再試
(`SkFontMgr_android_parser.cpp`);全部落空才不看語言,依 fonts.xml 的順序取第一個有該字的
family,也就是 `zh-Hans`。
- 官方文件對中文 locale 的建議:`supportedLocales` 寫 `Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW')`
等帶 script 的形式(https://docs.flutter.dev/ui/internationalization §「Chinese」)。

### 實機結果(2026-09-29,主對話):推論不成立

Android 模擬器(Medium_Phone)以臨時探測頁顯示「骨直這說」,`Locale` 依序為 `zh-Hant-TW`、`zh-Hant-HK`、`zh_TW`、`zh-Hans-CN`、`ja_JP`:前三列是繁中字形(「骨」下半為台灣寫法),`zh-Hans-CN` 是簡中字形,`ja_JP` 是日文字形。locale 能選到繁中字形,下面這段原始碼推論與實際不符,保留作紀錄;PR 12 只要讓文字帶正確的 locale(例如 `MaterialApp.locale`)。

### 原先的推論(不成立)

把 2–4 與上一段合起來:Flutter 送進 Skia 的是 `zh_Hant_TW`、`zh_Hant`、`zh_TW`(底線),
`getParent` 找不到 `-` 就直接變空,永遠比對不到 fonts.xml 的 `zh-Hant`;只有純語言碼
(`zh`、`ja`)比對得到,而 `zh` 會先比對到 `zh-Hans`。照原始碼推,**Android 上不論 locale 怎麼設,
漢字都拿到簡中字形**,繁中介面也一樣。

- 這是讀原始碼推出來的,還沒實機驗證。驗法:在 Android 模擬器用 `Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW')`
顯示「骨、直、這、說」,跟系統 TextView(例如設定頁)的繁中字形比。
- 若屬實,靠官方 API 做不到;可能的對策(PR 12 由擁有者決定):
- 接受 Android 用簡中字形;
- 內建 Noto Sans TC 子集或全字型(ADR 0024「CJK 字型不內建」要改);
- 向 Flutter 回報 locale 應以 BCP 47(`toLanguageTag()`)傳給 Skia。
- 沒找到直接回報這件事的 issue(搜尋 `zh_Hant`、`Traditional Chinese glyph Android`、`Locale toString underscore`)。
相關的舊 issue:#12576(2017,Android 日文字形,當時的修法是「把 locale 傳進文字渲染」)、
#16870(2018,Android 永遠是 CN 字形,併入 #12576)、#41138(CJK 選錯字形的追蹤 issue,2019 關閉)。
當時的引擎用 minikin,不是現在的 SkParagraph。

## Windows

- `SkFontMgr_New_DirectWrite` 以 DirectWrite 的系統字型集合解析名稱,`Microsoft JhengHei UI`、
`Microsoft YaHei UI` 這類系統 family 名稱對得到。
- flutter/flutter#103811(Windows 中文顯示異常,open):預設字型 `Segoe UI` 沒有中文,引擎自己做的
fallback 會混到 `Yu Gothic UI` 與 `Microsoft JhengHei UI`,字重也不一致;jason-simmons 的分析在
https://github.com/flutter/flutter/issues/103811 (2022-06-01)。社群的解法是
`TextStyle(fontFamilyFallback: ['Microsoft YaHei'])`
(https://github.com/flutter/flutter/issues/103811#issuecomment-2708024718 ),舊專案
`lib/ui/theme/app_theme.dart` 也用 `Microsoft YaHei UI`,Windows 上實際有效。
- 所以 Windows 照 ADR 0024 指名。英文介面繁中在前:出現漢字時先用正黑體。

## 對 ADR 0024 的更正

§決定 2 的「其他平台 `Noto Sans TC`/`Noto Sans SC`」在 Android 無效,改為不指名、交給 locale。
Linux/macOS/iOS 在各自的平台任務再查(Linux 的 fontconfig 通常認得 `Noto Sans CJK TC` 這類名稱,
但那時再驗)。
26 changes: 26 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-platform-layer/task.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "platform-layer",
"name": "platform-layer",
"title": "平台層與能力宣告",
"description": "M1 PR 4: PlatformCapabilities with the capabilities M1 uses (data directory, single instance, font fallback), Android and Windows implementations, unsupported-platform screen",
"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/platform-layer",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "09-28-m1-skeleton-tracer",
"relatedFiles": [],
"notes": "",
"meta": {}
}
18 changes: 17 additions & 1 deletion app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ ADR;dev 每一項都不同(ADR 0015 §決定 8)。
- Windows 的 ProductName 決定 path_provider 的目錄(`%APPDATA%\com.personal\<ProductName>`),
dev 的 application support、cache 等目錄因此全部與 prod 分開。

## 平台層

`lib/platform/`(ADR 0009)。怎麼加一個能力:`.trellis/spec/app/platform/index.md`。

- `PlatformCapabilities` 只含已經有實作的能力。Linux、macOS、iOS 驗證前宣告全部為
「沒有」、沒有實作檔;`main()` 看到沒有資料目錄就只開「此平台尚未支援」的畫面。
- 新能力連同實作一起加:宣告欄位、各平台實作、組裝點的分支、測試列在同一個 PR,
不先為之後的里程碑預留欄位。
- 平台判斷(`defaultTargetPlatform`、`TargetPlatform`、`Platform.isXxx`)只寫在組裝點
`lib/platform/platform.dart`;其他程式從 `AppPlatform` 拿宣告與實作。

閘門:`test/platform/platform_test.dart` 以注入的平台值逐平台核對宣告與實作(未驗證
平台必須全部為沒有);lint `fmp_platform_checks` 擋 `lib/platform/` 以外的平台判斷。
lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、以及「不預留欄位」沒有
自動閘門,review 時看。

## 資料目錄

`lib/platform/app_data_directory/`(ADR 0009 §決定 7)。閘門:
Expand Down Expand Up @@ -127,7 +143,7 @@ Flutter 3.47 起 Material 以獨立套件 `material_ui` 發佈,框架內的
照常忽略。
- `fmp_lints` 釘 `analyzer` 13.3.0,不是 pub.dev 最新:它和 `flutter_test` 同一個
workspace,`flutter_test` 釘的 `test_api` 讓 `analyzer_testing` 用不了 14.x。新 Flutter
放寬後三個套件一起升(`.trellis/tasks/09-29-fmp-lints/research/notes.md` §1)。
放寬後三個套件一起升(`.trellis/tasks/archive/2026-09/09-29-fmp-lints/research/notes.md` §1)。
- `riverpod_lint` 也接在 `plugins:`;`missing_provider_scope` 暫時關掉,第一個加
`ProviderScope` 的 PR 打開。
- 新規則怎麼加:`.trellis/spec/app/lints/index.md`。
14 changes: 8 additions & 6 deletions app/lib/app/fmp_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ 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});
const FmpApp({
super.key,
required this.flavor,
required this.dataDirectoryPath,
});

final AppFlavor flavor;

/// 平台沒有資料目錄實作時為 `null`。
final String? dataDirectoryPath;
final String dataDirectoryPath;

@override
Widget build(BuildContext context) {
Expand All @@ -25,7 +27,7 @@ class _IdentityPage extends StatelessWidget {
const _IdentityPage({required this.flavor, required this.dataDirectoryPath});

final AppFlavor flavor;
final String? dataDirectoryPath;
final String dataDirectoryPath;

@override
Widget build(BuildContext context) {
Expand All @@ -39,7 +41,7 @@ class _IdentityPage extends StatelessWidget {
style: Theme.of(context).textTheme.headlineMedium,
),
Text(flavor.name),
if (dataDirectoryPath case final path?) SelectableText(path),
SelectableText(dataDirectoryPath),
],
),
),
Expand Down
20 changes: 20 additions & 0 deletions app/lib/app/unsupported_platform_app.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:material_ui/material_ui.dart';

import 'package:fmp/core/app_flavor.dart';

/// 平台沒有資料目錄實作時的畫面(Linux、macOS、iOS 驗證前,ADR 0009
/// §決定 4)。不啟動資料層,只告訴使用者這個平台還不能用。
class UnsupportedPlatformApp extends StatelessWidget {
const UnsupportedPlatformApp({super.key, required this.flavor});

final AppFlavor flavor;

@override
Widget build(BuildContext context) {
return MaterialApp(
title: flavor.displayName,
// 先寫死繁中;slang 在 M1 PR 12 接上後改用翻譯字串。
home: const Scaffold(body: Center(child: Text('此平台尚未支援'))),
);
}
}
Loading
Loading