From 59ede54b70683ac4f4ca079a402c6d34bc3c06a6 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 12:52:14 +0800 Subject: [PATCH 1/4] feat(app): declare platform capabilities and font fallback Capabilities list only what the app implements; Linux, macOS and iOS declare none and open an unsupported-platform screen. Android leaves CJK glyph choice to the text locale, which picks Traditional forms. --- .trellis/spec/app/platform/index.md | 45 ++++++++++++ app/AGENTS.md | 18 ++++- app/lib/app/fmp_app.dart | 14 ++-- app/lib/app/unsupported_platform_app.dart | 20 ++++++ app/lib/main.dart | 14 +++- .../app_data_directory.dart | 34 +-------- app/lib/platform/fonts/fonts.dart | 33 +++++++++ app/lib/platform/fonts/fonts_android.dart | 11 +++ app/lib/platform/fonts/fonts_windows.dart | 13 ++++ app/lib/platform/platform.dart | 71 +++++++++++++++++++ app/lib/platform/platform_capabilities.dart | 36 ++++++++++ app/test/app/fmp_app_test.dart | 9 --- .../app/unsupported_platform_app_test.dart | 15 ++++ app/test/platform/fonts_test.dart | 38 ++++++++++ app/test/platform/platform_test.dart | 58 +++++++++++++++ 15 files changed, 378 insertions(+), 51 deletions(-) create mode 100644 .trellis/spec/app/platform/index.md create mode 100644 app/lib/app/unsupported_platform_app.dart create mode 100644 app/lib/platform/fonts/fonts.dart create mode 100644 app/lib/platform/fonts/fonts_android.dart create mode 100644 app/lib/platform/fonts/fonts_windows.dart create mode 100644 app/lib/platform/platform.dart create mode 100644 app/lib/platform/platform_capabilities.dart create mode 100644 app/test/app/unsupported_platform_app_test.dart create mode 100644 app/test/platform/fonts_test.dart create mode 100644 app/test/platform/platform_test.dart diff --git a/.trellis/spec/app/platform/index.md b/.trellis/spec/app/platform/index.md new file mode 100644 index 00000000..cfb9dde1 --- /dev/null +++ b/.trellis/spec/app/platform/index.md @@ -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` 每個平台都有一個斷言。 diff --git a/app/AGENTS.md b/app/AGENTS.md index 183b0aa0..498063b0 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -55,6 +55,22 @@ ADR;dev 每一項都不同(ADR 0015 §決定 8)。 - Windows 的 ProductName 決定 path_provider 的目錄(`%APPDATA%\com.personal\`), 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)。閘門: @@ -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`。 diff --git a/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart index d84e0ad5..fef19620 100644 --- a/app/lib/app/fmp_app.dart +++ b/app/lib/app/fmp_app.dart @@ -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) { @@ -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) { @@ -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), ], ), ), diff --git a/app/lib/app/unsupported_platform_app.dart b/app/lib/app/unsupported_platform_app.dart new file mode 100644 index 00000000..c29bccbb --- /dev/null +++ b/app/lib/app/unsupported_platform_app.dart @@ -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('此平台尚未支援'))), + ); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 1bd2776b..a6908df8 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -2,12 +2,20 @@ import 'package:flutter/services.dart' show appFlavor; import 'package:flutter/widgets.dart'; import 'package:fmp/app/fmp_app.dart'; +import 'package:fmp/app/unsupported_platform_app.dart'; import 'package:fmp/core/app_flavor.dart'; -import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/platform/platform.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final flavor = AppFlavor.parse(appFlavor); - final dataDirectory = await appDataDirectoryFor(flavor)?.resolve(); - runApp(FmpApp(flavor: flavor, dataDirectoryPath: dataDirectory?.path)); + final platform = AppPlatform.current(flavor); + // 沒有資料目錄的平台不啟動資料層(能力宣告 dataDirectory 為假)。 + switch (platform.dataDirectory) { + case null: + runApp(UnsupportedPlatformApp(flavor: flavor)); + case final dataDirectory: + final directory = await dataDirectory.resolve(); + runApp(FmpApp(flavor: flavor, dataDirectoryPath: directory.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 index 22439b97..6fe78811 100644 --- a/app/lib/platform/app_data_directory/app_data_directory.dart +++ b/app/lib/platform/app_data_directory/app_data_directory.dart @@ -1,47 +1,17 @@ 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]。 +/// 資料的位置,見 [ensureOutsideLegacyData]。實作只有 Android 與 Windows, +/// 由 `platform.dart` 組裝。 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({ diff --git a/app/lib/platform/fonts/fonts.dart b/app/lib/platform/fonts/fonts.dart new file mode 100644 index 00000000..cf991eb1 --- /dev/null +++ b/app/lib/platform/fonts/fonts.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +/// 決定 CJK 字型 fallback 順序的介面語言(ADR 0024 §決定 7 的三種語言)。 +enum FontLanguage { zhTw, zhCn, en } + +/// 各介面語言的 `TextStyle.fontFamilyFallback`(ADR 0024 §決定 2)。 +/// +/// 平台只提供繁中、簡中兩份清單;英文照 ADR 是繁中在前、簡中在後,由 +/// [familiesFor] 組出,各平台不必各寫一次。主題怎麼套用在 M1 PR 12。 +@immutable +final class FontFallback { + const FontFallback({ + required this.traditionalChinese, + required this.simplifiedChinese, + }); + + /// 沒有可指名的系統字型:字形交給引擎依文字的 locale 挑選,所以主題 + /// (M1 PR 12)要讓文字帶介面語言的 locale(例如 `MaterialApp.locale`)。 + static const none = FontFallback( + traditionalChinese: [], + simplifiedChinese: [], + ); + + final List traditionalChinese; + final List simplifiedChinese; + + /// [language] 的 fallback 字型,依優先順序排列。 + List familiesFor(FontLanguage language) => switch (language) { + FontLanguage.zhTw => traditionalChinese, + FontLanguage.zhCn => simplifiedChinese, + FontLanguage.en => [...traditionalChinese, ...simplifiedChinese], + }; +} diff --git a/app/lib/platform/fonts/fonts_android.dart b/app/lib/platform/fonts/fonts_android.dart new file mode 100644 index 00000000..d25f4932 --- /dev/null +++ b/app/lib/platform/fonts/fonts_android.dart @@ -0,0 +1,11 @@ +import 'package:fmp/platform/fonts/fonts.dart'; + +/// Android:不指名字型,繁簡字形交給引擎依文字的 locale 挑選。 +/// +/// 系統的 Noto Sans CJK 在 `/system/etc/fonts.xml` 裡是沒有名稱、只標 +/// `lang="zh-Hans"`/`lang="zh-Hant,zh-Bopo"` 的 fallback family;引擎的 +/// Android 字型管理器(Skia `SkFontMgr_Android`)只以名稱比對有名稱的 +/// family,所以寫 `Noto Sans TC` 對不到任何字型(ADR 0024 §決定 2 的更正)。 +/// 文字帶 `zh-Hant` 的 locale 時,模擬器實測拿到繁中字形(2026-09-29); +/// 來源與實測見 `.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md`。 +const androidFontFallback = FontFallback.none; diff --git a/app/lib/platform/fonts/fonts_windows.dart b/app/lib/platform/fonts/fonts_windows.dart new file mode 100644 index 00000000..56c3ee6e --- /dev/null +++ b/app/lib/platform/fonts/fonts_windows.dart @@ -0,0 +1,13 @@ +import 'package:fmp/platform/fonts/fonts.dart'; + +/// Windows:指名系統字型。 +/// +/// 引擎在 Windows 用 DirectWrite 的字型管理器,`fontFamilyFallback` 的名稱 +/// 對得到系統字型。不指名時預設字型 `Segoe UI` 沒有 CJK 字元,引擎自己挑的 +/// fallback 會混到 `Yu Gothic UI` 等日文字型、字重也不一致 +/// (flutter/flutter#103811)。來源與推導見 +/// `.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md`。 +const windowsFontFallback = FontFallback( + traditionalChinese: ['Microsoft JhengHei UI', 'Microsoft JhengHei'], + simplifiedChinese: ['Microsoft YaHei UI', 'Microsoft YaHei'], +); diff --git a/app/lib/platform/platform.dart b/app/lib/platform/platform.dart new file mode 100644 index 00000000..9d5012e2 --- /dev/null +++ b/app/lib/platform/platform.dart @@ -0,0 +1,71 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.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:fmp/platform/fonts/fonts_android.dart'; +import 'package:fmp/platform/fonts/fonts_windows.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; + +/// 平台層的組裝點(ADR 0009 §決定 1–4):依平台組出能力宣告與各能力的 +/// 實作。整個 App 只有這裡判斷平台;lint `fmp_platform_checks` 擋的是 +/// `lib/platform/` 以外。 +final class AppPlatform { + AppPlatform._({required this.capabilities, this.dataDirectory}) + : assert(capabilities.dataDirectory == (dataDirectory != null)); + + /// 目前執行的平台。 + factory AppPlatform.current(AppFlavor flavor) => + AppPlatform.assemble(defaultTargetPlatform, flavor); + + /// 依 [platform] 組裝。 + /// + /// 只有 Android 與 Windows 有實作;其他平台驗證前宣告全部為「沒有」、 + /// 也沒有實作檔(ADR 0009 §決定 4)。 + factory AppPlatform.assemble(TargetPlatform platform, AppFlavor flavor) => + switch (platform) { + TargetPlatform.android => AppPlatform._( + capabilities: const PlatformCapabilities( + dataDirectory: true, + singleInstance: false, + fontFallback: androidFontFallback, + ), + dataDirectory: AndroidAppDataDirectory( + flavor: flavor, + applicationSupportPath: () async => + (await getApplicationSupportDirectory()).path, + ), + ), + TargetPlatform.windows => AppPlatform._( + capabilities: const PlatformCapabilities( + dataDirectory: true, + singleInstance: true, + fontFallback: windowsFontFallback, + ), + dataDirectory: WindowsAppDataDirectory( + flavor: flavor, + executablePath: Platform.resolvedExecutable, + roamingAppDataPath: Platform.environment['APPDATA'], + applicationSupportPath: () async => + (await getApplicationSupportDirectory()).path, + documentsPath: () async => + (await getApplicationDocumentsDirectory()).path, + ), + ), + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.iOS || + TargetPlatform.fuchsia => AppPlatform._( + capabilities: PlatformCapabilities.none, + ), + }; + + final PlatformCapabilities capabilities; + + /// App 資料目錄;[PlatformCapabilities.dataDirectory] 為假時為 `null`。 + final AppDataDirectory? dataDirectory; +} diff --git a/app/lib/platform/platform_capabilities.dart b/app/lib/platform/platform_capabilities.dart new file mode 100644 index 00000000..a93bc1f6 --- /dev/null +++ b/app/lib/platform/platform_capabilities.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/platform/fonts/fonts.dart'; + +/// 目前平台有哪些能力(ADR 0009 §決定 2)。每個平台一份,由 +/// `platform.dart` 組出;UI 依它決定是否顯示入口。 +/// +/// 只列已經有實作的能力:新能力連同實作一起加欄位(ADR 0009 §決定 4), +/// 不先為之後的里程碑預留。 +@immutable +final class PlatformCapabilities { + const PlatformCapabilities({ + required this.dataDirectory, + required this.singleInstance, + required this.fontFallback, + }); + + /// 還沒驗證的平台:什麼都沒有。 + static const none = PlatformCapabilities( + dataDirectory: false, + singleInstance: false, + fontFallback: FontFallback.none, + ); + + /// 有 App 資料目錄的實作(`app_data_directory/`)。沒有時 `main()` 不啟動 + /// 資料層,只顯示「此平台尚未支援」。 + final bool dataDirectory; + + /// 原生端保證只跑一個實例、再次啟動時把第一個帶到前景。Windows 由 + /// `windows/runner/main.cpp` 的 mutex 實作(`app/AGENTS.md` § App 身分), + /// Dart 端只宣告。 + final bool singleInstance; + + /// 各介面語言的 CJK 字型 fallback(ADR 0024 §決定 2)。 + final FontFallback fontFallback; +} diff --git a/app/test/app/fmp_app_test.dart b/app/test/app/fmp_app_test.dart index 854558c8..7042b8ff 100644 --- a/app/test/app/fmp_app_test.dart +++ b/app/test/app/fmp_app_test.dart @@ -12,13 +12,4 @@ void main() { 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/app/unsupported_platform_app_test.dart b/app/test/app/unsupported_platform_app_test.dart new file mode 100644 index 00000000..cd79c812 --- /dev/null +++ b/app/test/app/unsupported_platform_app_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/unsupported_platform_app.dart'; +import 'package:fmp/core/app_flavor.dart'; + +void main() { + testWidgets('tells the user the platform is not supported yet', ( + tester, + ) async { + await tester.pumpWidget( + const UnsupportedPlatformApp(flavor: AppFlavor.dev), + ); + + expect(find.text('此平台尚未支援'), findsOneWidget); + }); +} diff --git a/app/test/platform/fonts_test.dart b/app/test/platform/fonts_test.dart new file mode 100644 index 00000000..e49d4763 --- /dev/null +++ b/app/test/platform/fonts_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/platform/fonts/fonts_android.dart'; +import 'package:fmp/platform/fonts/fonts_windows.dart'; + +void main() { + group('Windows', () { + test('Traditional Chinese uses JhengHei', () { + expect(windowsFontFallback.familiesFor(FontLanguage.zhTw), [ + 'Microsoft JhengHei UI', + 'Microsoft JhengHei', + ]); + }); + + test('Simplified Chinese uses YaHei', () { + expect(windowsFontFallback.familiesFor(FontLanguage.zhCn), [ + 'Microsoft YaHei UI', + 'Microsoft YaHei', + ]); + }); + + test('English puts the Traditional list before the Simplified one', () { + expect(windowsFontFallback.familiesFor(FontLanguage.en), [ + 'Microsoft JhengHei UI', + 'Microsoft JhengHei', + 'Microsoft YaHei UI', + 'Microsoft YaHei', + ]); + }); + }); + + test('Android names no fonts in any language', () { + // 系統 Noto CJK 沒有 family 名稱,指名無效(fonts_android.dart)。 + for (final language in FontLanguage.values) { + expect(androidFontFallback.familiesFor(language), isEmpty); + } + }); +} diff --git a/app/test/platform/platform_test.dart b/app/test/platform/platform_test.dart new file mode 100644 index 00000000..0eefd8c0 --- /dev/null +++ b/app/test/platform/platform_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.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'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/platform/fonts/fonts_android.dart'; +import 'package:fmp/platform/fonts/fonts_windows.dart'; +import 'package:fmp/platform/platform.dart'; + +void main() { + group('AppPlatform.assemble', () { + test('Android has a data directory and picks glyphs by locale', () { + final platform = AppPlatform.assemble( + TargetPlatform.android, + AppFlavor.dev, + ); + + expect(platform.capabilities.dataDirectory, isTrue); + expect(platform.dataDirectory, isA()); + expect(platform.capabilities.singleInstance, isFalse); + expect(platform.capabilities.fontFallback, same(androidFontFallback)); + }); + + test('Windows has a data directory, a single instance and named fonts', () { + final platform = AppPlatform.assemble( + TargetPlatform.windows, + AppFlavor.dev, + ); + + expect(platform.capabilities.dataDirectory, isTrue); + expect(platform.dataDirectory, isA()); + expect(platform.capabilities.singleInstance, isTrue); + expect(platform.capabilities.fontFallback, same(windowsFontFallback)); + }); + + for (final unverified in [ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.iOS, + TargetPlatform.fuchsia, + ]) { + test('${unverified.name} declares nothing and has no implementation', () { + final platform = AppPlatform.assemble(unverified, AppFlavor.prod); + + expect(platform.capabilities.dataDirectory, isFalse); + expect(platform.dataDirectory, isNull); + expect(platform.capabilities.singleInstance, isFalse); + for (final language in FontLanguage.values) { + expect( + platform.capabilities.fontFallback.familiesFor(language), + isEmpty, + ); + } + }); + } + }); +} From 1473abf49b66c66dc01ae96d73cc552fe46ccf88 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 12:52:23 +0800 Subject: [PATCH 2/4] docs(adr): correct the android font fallback in adr 0024 --- docs/adr/0024-ui-ux-design-system.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0024-ui-ux-design-system.md b/docs/adr/0024-ui-ux-design-system.md index 8e87d71e..15b07650 100644 --- a/docs/adr/0024-ui-ux-design-system.md +++ b/docs/adr/0024-ui-ux-design-system.md @@ -145,6 +145,7 @@ - Material 拆成 `material_ui` 的遷移時程; - Flutter 若提供官方 window size class 或液態玻璃支援時再評估; - Linux 的 CJK 字型內建由平台任務決定。 +- 更正(2026-09-29):§決定 2 的 `Noto Sans TC`/`Noto Sans SC` 在 Android 無效。系統的 Noto CJK 在 `fonts.xml` 是沒有名稱的 fallback family,引擎以名稱找不到,所以 Android 不指名字型,繁簡字形交給文字的 locale;模擬器實測 `zh-Hant` 的 locale 拿到繁中字形(來源與實測見 `.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md`,M1 PR 4)。 ## 如何確認 From 55c16ca5b5a32f141b2d52623b26a56922593630 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 12:52:28 +0800 Subject: [PATCH 3/4] chore(task): note the locale follow-up for the ui pr --- .trellis/tasks/09-28-m1-skeleton-tracer/implement.md | 1 + .trellis/tasks/09-28-m1-skeleton-tracer/task.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index 27232f7c..d4e14352 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -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 去重、取代與時長; diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 772d57f0..af380ce1 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -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": [], From ba80fb763a5b22bb7c2c68ed5dbee3f99010931f Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 12:52:53 +0800 Subject: [PATCH 4/4] chore(task): archive platform-layer --- .../2026-09/09-29-platform-layer/check.jsonl | 4 + .../09-29-platform-layer/implement.jsonl | 4 + .../2026-09/09-29-platform-layer/prd.md | 60 +++++++++++++ .../09-29-platform-layer/research/notes.md | 85 +++++++++++++++++++ .../2026-09/09-29-platform-layer/task.json | 26 ++++++ 5 files changed, 179 insertions(+) create mode 100644 .trellis/tasks/archive/2026-09/09-29-platform-layer/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-platform-layer/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-platform-layer/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-platform-layer/task.json diff --git a/.trellis/tasks/archive/2026-09/09-29-platform-layer/check.jsonl b/.trellis/tasks/archive/2026-09/09-29-platform-layer/check.jsonl new file mode 100644 index 00000000..61a9ab0f --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-platform-layer/check.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-29-platform-layer/implement.jsonl b/.trellis/tasks/archive/2026-09/09-29-platform-layer/implement.jsonl new file mode 100644 index 00000000..61a9ab0f --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-platform-layer/implement.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-29-platform-layer/prd.md b/.trellis/tasks/archive/2026-09/09-29-platform-layer/prd.md new file mode 100644 index 00000000..1f9979f0 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-platform-layer/prd.md @@ -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 已守)。 diff --git a/.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md b/.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md new file mode 100644 index 00000000..779a72c4 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md @@ -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 是 + ``、``、``、``, + **沒有 `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` 這類名稱, +但那時再驗)。 diff --git a/.trellis/tasks/archive/2026-09/09-29-platform-layer/task.json b/.trellis/tasks/archive/2026-09/09-29-platform-layer/task.json new file mode 100644 index 00000000..96ee8aa9 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-platform-layer/task.json @@ -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": {} +} \ No newline at end of file