From 5df1b1927f751c261ebbe4dd27545a31f14e574a Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 18:49:55 +0800 Subject: [PATCH 1/8] feat(app): let appearance settings return to following the system --- .../appearance_settings_repository.dart | 16 ++++++ app/lib/settings/appearance_settings.dart | 23 +++++--- .../appearance_settings_repository_test.dart | 55 +++++++++++++++++++ .../settings/appearance_settings_test.dart | 34 ++++++++++++ 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/app/lib/data/repositories/appearance_settings_repository.dart b/app/lib/data/repositories/appearance_settings_repository.dart index 0f8d44f7..0d707ded 100644 --- a/app/lib/data/repositories/appearance_settings_repository.dart +++ b/app/lib/data/repositories/appearance_settings_repository.dart @@ -63,6 +63,22 @@ final class AppearanceSettingsRepository { ), ); + /// 把傳 `true` 的欄位清回 `null`(沒設定過,由上層套用預設);其他欄位不動。 + /// + /// 和 [write] 分開:[write] 的 `null` 表示「沒給、不動」,所以清空另走這裡。 + Future clear({bool themeMode = false, bool locale = false}) { + if (!themeMode && !locale) return Future.value(); + return _database + .into(_database.appearanceSettingsTable) + .insertOnConflictUpdate( + AppearanceSettingsTableCompanion( + id: const Value(_rowId), + themeMode: themeMode ? const Value(null) : const Value.absent(), + locale: locale ? const Value(null) : const Value.absent(), + ), + ); + } + static AppearanceSettings _fromRow(AppearanceSettingsRow? row) => row == null ? AppearanceSettings.empty : AppearanceSettings(themeMode: row.themeMode, locale: row.locale); diff --git a/app/lib/settings/appearance_settings.dart b/app/lib/settings/appearance_settings.dart index 4c950b0f..c315601b 100644 --- a/app/lib/settings/appearance_settings.dart +++ b/app/lib/settings/appearance_settings.dart @@ -122,12 +122,19 @@ final class AppearanceNotifier extends StreamNotifier { ); } - /// 只寫主題模式這個欄位。 - Future setThemeMode(ThemeModeSetting themeMode) => ref - .read(appearanceSettingsRepositoryProvider) - .write(themeMode: themeMode); - - /// 只寫語言這個欄位。 - Future setLocale(LocaleSetting locale) => - ref.read(appearanceSettingsRepositoryProvider).write(locale: locale); + /// 只寫主題模式這個欄位;`null` 清回沒設定過(跟隨預設,也就是系統)。 + Future setThemeMode(ThemeModeSetting? themeMode) { + final repository = ref.read(appearanceSettingsRepositoryProvider); + return themeMode == null + ? repository.clear(themeMode: true) + : repository.write(themeMode: themeMode); + } + + /// 只寫語言這個欄位;`null` 清回沒設定過(跟隨系統的語言偏好)。 + Future setLocale(LocaleSetting? locale) { + final repository = ref.read(appearanceSettingsRepositoryProvider); + return locale == null + ? repository.clear(locale: true) + : repository.write(locale: locale); + } } diff --git a/app/test/data/repositories/appearance_settings_repository_test.dart b/app/test/data/repositories/appearance_settings_repository_test.dart index 1cfda793..23555b25 100644 --- a/app/test/data/repositories/appearance_settings_repository_test.dart +++ b/app/test/data/repositories/appearance_settings_repository_test.dart @@ -45,6 +45,61 @@ void main() { ); }); + group('clear', () { + test('sets only the named fields back to unset', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + await repository.write( + themeMode: ThemeModeSetting.dark, + locale: LocaleSetting.en, + ); + + await repository.clear(locale: true); + expect( + await repository.read(), + const AppearanceSettings(themeMode: ThemeModeSetting.dark), + ); + + await repository.clear(themeMode: true); + expect(await repository.read(), AppearanceSettings.empty); + }); + + test('stores NULL, not a default value', () async { + final database = memoryDatabase(); + final repository = AppearanceSettingsRepository(database); + await repository.write( + themeMode: ThemeModeSetting.light, + locale: LocaleSetting.zhCn, + ); + + await repository.clear(themeMode: true, locale: true); + + final row = await database + .customSelect('SELECT theme_mode, locale FROM appearance_settings') + .getSingle(); + expect(row.data, {'theme_mode': null, 'locale': null}); + }); + + test('with nothing named changes nothing', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + await repository.write(locale: LocaleSetting.zhTw); + + await repository.clear(); + + expect( + await repository.read(), + const AppearanceSettings(locale: LocaleSetting.zhTw), + ); + }); + + test('before any write leaves the row unset', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + + await repository.clear(themeMode: true); + + expect(await repository.read(), AppearanceSettings.empty); + }); + }); + test('watch emits the current value and every write', () async { final repository = AppearanceSettingsRepository(memoryDatabase()); final events = StreamIterator(repository.watch()); diff --git a/app/test/settings/appearance_settings_test.dart b/app/test/settings/appearance_settings_test.dart index e93d9c62..b0ca4973 100644 --- a/app/test/settings/appearance_settings_test.dart +++ b/app/test/settings/appearance_settings_test.dart @@ -194,6 +194,40 @@ void main() { expect(values.current.locale, LocaleSetting.zhCn); }); + test('null clears a field back to following the system', () async { + setSystemLocale(const Locale('en', 'US')); + final database = memoryDatabase(); + final container = containerFor(database); + final values = appearances(container); + expect(await values.moveNext(), isTrue); + final notifier = container.read(appearanceProvider.notifier); + await notifier.setLocale(LocaleSetting.zhCn); + expect(await values.moveNext(), isTrue); + await notifier.setThemeMode(ThemeModeSetting.dark); + expect(await values.moveNext(), isTrue); + + await notifier.setLocale(null); + expect(await values.moveNext(), isTrue); + // 語言回到系統的;主題不動。 + expect(values.current.locale, LocaleSetting.en); + expect(values.current.stored.locale, isNull); + expect(values.current.themeMode, ThemeModeSetting.dark); + + await notifier.setThemeMode(null); + expect(await values.moveNext(), isTrue); + expect(values.current.themeMode, ThemeModeSetting.system); + expect(values.current.stored, AppearanceSettings.empty); + + // 清掉之後又跟著系統變。 + setSystemLocale(const Locale('zh', 'TW')); + expect(await values.moveNext(), isTrue); + expect(values.current.locale, LocaleSetting.zhTw); + final row = await database + .customSelect('SELECT theme_mode, locale FROM appearance_settings') + .getSingle(); + expect(row.data, {'theme_mode': null, 'locale': null}); + }); + test('each setter writes only its own field', () async { setSystemLocale(const Locale('en', 'US')); final container = containerFor(memoryDatabase()); From 618722c7268faed79333e51fb2da36fc678bdd15 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 18:49:56 +0800 Subject: [PATCH 2/8] feat(app): add theme tokens, translations and toasts --- .github/workflows/ci.yml | 5 +- app/AGENTS.md | 68 +++- app/build.yaml | 30 +- app/lib/app/app_material.dart | 39 +++ app/lib/app/database_error_app.dart | 47 ++- app/lib/app/fmp_app.dart | 142 +++++++- app/lib/app/unsupported_platform_app.dart | 26 +- app/lib/core/errors/app_error.dart | 20 +- app/lib/i18n/en.i18n.json | 34 ++ app/lib/i18n/strings.g.dart | 106 ++++++ app/lib/i18n/strings_en.g.dart | 162 +++++++++ app/lib/i18n/strings_zh_CN.g.dart | 159 +++++++++ app/lib/i18n/strings_zh_TW.g.dart | 164 +++++++++ app/lib/i18n/zh-CN.i18n.json | 34 ++ app/lib/i18n/zh-TW.i18n.json | 34 ++ app/lib/main.dart | 6 +- app/lib/ui/errors/error_message.dart | 45 +++ app/lib/ui/i18n/ui_locale.dart | 98 ++++++ app/lib/ui/layout/window_class.dart | 62 ++++ app/lib/ui/settings/appearance_controls.dart | 86 +++++ app/lib/ui/theme/app_layout.dart | 6 + app/lib/ui/theme/app_theme.dart | 69 ++++ app/lib/ui/theme/app_tokens.dart | 157 +++++++++ app/lib/ui/toast/toast_host.dart | 182 ++++++++++ app/lib/ui/toast/toaster.dart | 140 ++++++++ app/pubspec.lock | 28 +- app/pubspec.yaml | 13 + app/test/app/database_error_app_test.dart | 48 ++- app/test/app/fmp_app_test.dart | 228 ++++++++++++- .../app/unsupported_platform_app_test.dart | 33 +- .../core/errors/app_error_surface_test.dart | 20 +- app/test/core/errors/app_error_test.dart | 4 +- app/test/i18n/translations_test.dart | 168 ++++++++++ app/test/ui/errors/error_message_test.dart | 98 ++++++ app/test/ui/guidelines_test.dart | 156 +++++++++ app/test/ui/i18n/ui_locale_test.dart | 75 +++++ app/test/ui/layout/window_class_test.dart | 71 ++++ app/test/ui/theme/app_theme_test.dart | 201 +++++++++++ app/test/ui/toast/toast_host_test.dart | 317 ++++++++++++++++++ app/test/ui/toast/toaster_test.dart | 197 +++++++++++ 40 files changed, 3468 insertions(+), 110 deletions(-) create mode 100644 app/lib/app/app_material.dart create mode 100644 app/lib/i18n/en.i18n.json create mode 100644 app/lib/i18n/strings.g.dart create mode 100644 app/lib/i18n/strings_en.g.dart create mode 100644 app/lib/i18n/strings_zh_CN.g.dart create mode 100644 app/lib/i18n/strings_zh_TW.g.dart create mode 100644 app/lib/i18n/zh-CN.i18n.json create mode 100644 app/lib/i18n/zh-TW.i18n.json create mode 100644 app/lib/ui/errors/error_message.dart create mode 100644 app/lib/ui/i18n/ui_locale.dart create mode 100644 app/lib/ui/layout/window_class.dart create mode 100644 app/lib/ui/settings/appearance_controls.dart create mode 100644 app/lib/ui/theme/app_layout.dart create mode 100644 app/lib/ui/theme/app_theme.dart create mode 100644 app/lib/ui/theme/app_tokens.dart create mode 100644 app/lib/ui/toast/toast_host.dart create mode 100644 app/lib/ui/toast/toaster.dart create mode 100644 app/test/i18n/translations_test.dart create mode 100644 app/test/ui/errors/error_message_test.dart create mode 100644 app/test/ui/guidelines_test.dart create mode 100644 app/test/ui/i18n/ui_locale_test.dart create mode 100644 app/test/ui/layout/window_class_test.dart create mode 100644 app/test/ui/theme/app_theme_test.dart create mode 100644 app/test/ui/toast/toast_host_test.dart create mode 100644 app/test/ui/toast/toaster_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb73dcc1..56ac4d6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,8 +227,9 @@ 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 § 資料層)。重跑 + # codegen 後 app/ 有任何變動或新檔,就是改了 table 或翻譯檔卻沒重跑 + # build_runner(slang_build_runner 讓兩者一起產生)。 # 路徑用 `.`:這個 job 的工作目錄已經是 app/。 - name: Check generated code is up to date run: | diff --git a/app/AGENTS.md b/app/AGENTS.md index 922b6e3d..2036985a 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -13,6 +13,7 @@ | `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | | 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | | drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | +| 翻譯(`lib/i18n/*.i18n.json`)或 `build.yaml` 的 slang 設定 | 先 `dart run build_runner build`,再跑第一列 | | 播放後端(`lib/playback/backends/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/audio_backend_contract_test.dart -d <裝置>`(見 § 播放) | - `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 @@ -124,10 +125,12 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 的分支本身沒有測試。 - 外鍵每次開啟都在 `beforeOpen` 打開(SQLite 預設關、只對當前連線有效;ADR 0019 §決定 1)。 閘門:`test/data/database/app_database_test.dart` 與 repository 測試的 cascade 案例。 -- drift 產生的 `*.g.dart` 提交進 repo(`app/.gitignore` 覆寫根目錄對 `*.g.dart` 的忽略), - 拉下來不用先跑 codegen。改了 table 或 `@DriftDatabase` 就重跑 - `dart run build_runner build` 並提交產生檔。閘門:CI `app` job 的 - 「Check generated code is up to date」;本機沒有東西擋。 +- drift 與 slang(§ 介面)產生的 `*.g.dart` 提交進 repo(`app/.gitignore` 覆寫根目錄對 + `*.g.dart` 的忽略),拉下來不用先跑 codegen。改了 table、`@DriftDatabase` 或翻譯檔就重跑 + `dart run build_runner build`(兩者一起產生)並提交產生檔。閘門:CI `app` job 的 + 「Check generated code is up to date」;本機沒有東西擋。在 Windows 上重跑會把產生檔與 + `linux/`、`macos/`、`windows/` 的 plugin registrant 改成 LF,內容沒變的(`git diff + --ignore-all-space --ignore-cr-at-eol` 為空)直接還原。 - schema 快照在 `drift_schemas/app_database/`。閘門:`test/drift/app_database/schema_test.dart` ——程式碼建出的 schema 必須等於最新快照,`schemaVersion` 必須等於最新快照的版本。 - 持久化格式:列舉存 `lib/data/database/converters.dart` 寫死的字串(不是 enum 的 @@ -183,9 +186,10 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 音源邊界以上只看得到 `AppError`:音源把自己的錯誤碼轉好,其他例外在邊界以 `AppError.wrap` 包成 `UnexpectedError`。閘門:PR 9 的插件契約測試;在那之前沒有。 - `AppError` 沒有可以直接顯示的字串:使用者訊息只有 `messageKey`/`messageArgs`; - 原始 error 與 stackTrace 是函式庫私有欄位,`toString()` 不含它們。閘門: - `test/core/errors/app_error_surface_test.dart`(列出全部公開成員,含變異案例)、 - `app_error_test.dart` 的 `toString`。 + `messageArgs` 是 `Map`,放不進插件或伺服器的文字;音源名稱由呈現層 + 以 `pluginId` 查。原始 error 與 stackTrace 是函式庫私有欄位,`toString()` 不含它們。閘門: + `test/core/errors/app_error_surface_test.dart`(列出全部公開成員;型別參數裡的 + `String`/`Object`/`dynamic` 也算,含變異案例)、`app_error_test.dart` 的 `toString`。 - 被處理的錯誤一律經 `log.report(...)`(`report_error.dart`,`app_error.dart` 的 `part`) 寫進錯誤歷史,那是唯一讀得到原始 error 的路徑。閘門:`report_error_test.dart` 驗層級、欄位與遮蔽;「處理了卻沒 report」沒有閘門,review 時看。 @@ -350,13 +354,13 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 恢復(ADR 0018 §決定 7 的 M1 部分):網路錯誤、限流、中斷與提前結束從目前位置重試 1/3/9 秒;開不起來換下一個候選一次;其他錯誤類別跳過;連續跳過達佇列長度(最多 10) 停在 `Failed`。M1 沒有連線偵測、試聽片段設定(一律跳過)、緩衝飢餓與輸出裝置的處理、 - 「正常播放 10 秒後重試計數歸零」(M1 換歌才歸零),也沒有提示 UI(PR 12)。閘門:`recovery_policy_test.dart`、`playback_controller_test.dart` + 「正常播放 10 秒後重試計數歸零」(M1 換歌才歸零),也沒有提示 UI(PR 12b)。閘門:`recovery_policy_test.dart`、`playback_controller_test.dart` 的 `recovery` 群組。 - 被取代的解析結果丟掉,但插件的 `resolveStream` 沒有取消參數,網路工作不取消 (ADR 0018 §決定 6 的取消等插件 API 支援)。已知限制。 - 播放的開發入口:dev flavor 帶 `--fmp-dev-playback` 啟動就安裝內附的測試插件並依序播 它的三首;`--fmp-dev-playback=<曲目鍵>`(可重複;不用逗號,Android 的 `--esal` 以逗號切 - 陣列)播指定的曲目(插件同時以 `--fmp-dev-plugin` 安裝)。prod 不讀,理由同插件的開發入口。PR 12 的播放列能走同一條路後刪掉。閘門: + 陣列)播指定的曲目(插件同時以 `--fmp-dev-plugin` 安裝)。prod 不讀,理由同插件的開發入口。PR 12b 的播放列能走同一條路後刪掉。閘門: `dev_playback_entry_test.dart` 的 `prod reads nothing`。 ## 設定 @@ -371,6 +375,52 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 分辨「跟隨系統」。 - 語言沒設定過時跟隨系統的語言偏好清單(執行中改變也跟):取清單中第一個對得到 zh-TW/zh-CN/en 的,全都對不到才用 base locale zh-TW(ADR 0024 §決定 7)。 +- 「跟隨系統」是把欄位清回 `null`(repository 的 `clear`、Notifier setter 傳 `null`),不是 + 存 `system` 之類的值;`write` 的 `null` 是「沒給、不動」。閘門: + `appearance_settings_repository_test.dart` 的 `clear` 群組(直接查表是 `NULL`)、 + `appearance_settings_test.dart` 的 `null clears a field back to following the system`。 + +## 介面 + +`lib/ui/`、`lib/i18n/`(ADR 0023、ADR 0024)。怎麼用 token、加字串、跳提示: +`.trellis/spec/app/ui/index.md`。 + +- 間距、圓角、顏色只從 `lib/ui/theme/`(`AppTokens`、`AppLayout`、`ColorScheme`)取,字級只用 + `TextTheme` 的角色。閘門:lint `fmp_design_tokens`(`lib/ui/`,theme 目錄豁免)。`lib/app/` + 不在它的範圍;身分頁(12b 換掉)照樣用 token,但沒有閘門。 +- 使用者看到的字串只來自 `lib/i18n/*.i18n.json`;base locale 是 zh-TW,缺字在執行時退回繁中, + 所以編譯擋不住漏翻。閘門:`test/i18n/translations_test.dart`(三個語言的 key 與 `{參數}` + 相同、每個 `ErrorMessageKey`/`UnavailableReason` 都有字串;含變異案例)。widget 裡寫死的 + 字串沒有閘門,review 時看;身分頁的 `Dev playback:` 之類是開發用標籤,刻意不翻。 +- 翻譯只經 `translationsProvider`(`lib/ui/i18n/ui_locale.dart`)注入:`build.yaml` 設 + `locale_handling: false`,slang 不產生全域 `t`/`LocaleSettings`,語言狀態只有外觀設定一份。 +- `MaterialApp.locale` 一律給帶書寫系統的 locale(`zh-Hant-TW`、`zh-Hans-CN`、`en`),由 + `flutterLocaleOf` 從 `LocaleSetting` 對出;它決定 Android 的繁簡字形與 Material 內建字串。 + `localizationsDelegates` 用 `material_ui` 的 `GlobalMaterialLocalizations.delegates`,不是 + `flutter_localizations` 的(後者給的是凍結的 material.dart 型別)。閘門: + `test/ui/i18n/ui_locale_test.dart`(三種語言的 Material 字串)、`test/app/fmp_app_test.dart` + 的 `appearance` 群組。 +- 主題的 `fontFamilyFallback` 取平台層依介面語言排序的清單(ADR 0024 §決定 2);App 啟動與每次 + 換語言寫一筆 `UI locale applied`(`locale`、`fontFallback`)。閘門:`fmp_app_test.dart` 的 + `the theme uses the platform fonts for the UI language`。字形是否正確只能實機看(身分頁的 + 字形樣本)。 +- 主題的每個文字樣式帶 `textLocaleOf` 的 locale(中文介面同介面語言,英文介面是繁中): + Android 不指名字型,英文介面的漢字不帶它就落到簡中字形。樣式的 locale 蓋過 `Text.locale`, + 要另一種字形的文字在樣式上指定。閘門:`app_theme_test.dart` 的 + `widgets inherit the text locale`、`fmp_app_test.dart` 的 + `CJK text takes the glyphs of the UI language`。 +- 提示只經 `Toaster`(`toasterProvider`):`SnackBar`、`ScaffoldMessenger.of` 等只准在 + `lib/ui/toast/`。閘門:lint `fmp_toast_entry`。`error` 只收 `AppError`,並自己 + `log.report`(被去重掉的也寫)。閘門:`test/ui/toast/toaster_test.dart`。背景工作不呼叫 + `Toaster`:沒有閘門,review 時看。 +- `ToastHost` 在 `MaterialApp.builder`,以自己的 `Overlay`、`ScaffoldMessenger`、透明 + `Scaffold` 包住 Navigator:提示在全螢幕頁、對話框、底部面板之上,一次一則、新的取代舊的, + 時長 4/6 秒、帶動作也照時長消失、無障礙導覽時停留並有關閉鈕、App 在背景(hidden/paused) + 不顯示,位置避開 `toastBottomInsetProvider`。閘門:`test/ui/toast/toast_host_test.dart`。 +- 淺色與深色主題下,示範畫面、外觀設定控制項與四種提示通過點擊區與對比度 guideline。閘門: + `test/ui/guidelines_test.dart`。12b 的正式頁面要各自加進去。 +- `WindowClass` 與 M3 同值(600/840/1200/1600,下限含在高的一級)。閘門: + `test/ui/layout/window_class_test.dart`。 ## 零聯網 diff --git a/app/build.yaml b/app/build.yaml index 14a92aa8..24a9e6e3 100644 --- a/app/build.yaml +++ b/app/build.yaml @@ -1,11 +1,33 @@ -# drift 的程式碼產生器設定(https://drift.simonbinder.eu/migrations/)。 -# `databases:` 給 `dart run drift_dev make-migrations` 找資料庫類別;快照存在 -# drift_schemas/app_database/,測試輔助碼在 test/drift/app_database/generated/ -# (兩者都是 make-migrations 的預設位置)。步驟見 .trellis/spec/app/data/index.md。 +# 程式碼產生器設定;`dart run build_runner build` 一次跑完兩者,產生檔都提交 +# (app/AGENTS.md § 資料層、§ 介面)。 targets: $default: builders: + # drift(https://drift.simonbinder.eu/migrations/)。`databases:` 給 + # `dart run drift_dev make-migrations` 找資料庫類別;快照存在 + # drift_schemas/app_database/,測試輔助碼在 test/drift/app_database/generated/ + # (兩者都是 make-migrations 的預設位置)。步驟見 .trellis/spec/app/data/index.md。 drift_dev: options: databases: app_database: lib/data/database/app_database.dart + # slang(ADR 0024 §決定 7)。選項的理由見 .trellis/spec/app/ui/index.md 開頭 + # 連到的研究檔。 + slang_build_runner: + options: + base_locale: zh-TW + fallback_strategy: base_locale + input_directory: lib/i18n + input_file_pattern: .i18n.json + output_directory: lib/i18n + output_file_name: strings.g.dart + lazy: false + locale_handling: false + flutter_integration: false + translation_class_visibility: public + string_interpolation: braces + timestamp: false + flat_map: false + format: + enabled: true + width: 80 diff --git a/app/lib/app/app_material.dart b/app/lib/app/app_material.dart new file mode 100644 index 00000000..678c5b24 --- /dev/null +++ b/app/lib/app/app_material.dart @@ -0,0 +1,39 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; + +/// App 根元件共用的 `MaterialApp`(ADR 0024 §決定 1、2、7):淺色與深色主題、 +/// 介面語言(帶書寫系統的 locale,見 `flutterLocaleOf`)、Material 的內建字串、 +/// 漢字的字形(`textLocaleOf`)。 +/// +/// `localizationsDelegates` 用 `material_ui` 的 `GlobalMaterialLocalizations`, +/// 不是 `flutter_localizations` 的:後者提供的是凍結的 +/// `package:flutter/material.dart` 的型別(`material_ui` 的 README)。 +MaterialApp fmpMaterialApp({ + required String title, + required LocaleSetting locale, + required ThemeMode themeMode, + required List fontFamilyFallback, + required Widget home, + TransitionBuilder? builder, +}) => MaterialApp( + title: title, + theme: buildAppTheme( + Brightness.light, + fontFamilyFallback: fontFamilyFallback, + textLocale: textLocaleOf(locale), + ), + darkTheme: buildAppTheme( + Brightness.dark, + fontFamilyFallback: fontFamilyFallback, + textLocale: textLocaleOf(locale), + ), + themeMode: themeMode, + locale: flutterLocaleOf(locale), + supportedLocales: supportedFlutterLocales, + localizationsDelegates: GlobalMaterialLocalizations.delegates, + builder: builder, + home: home, +); diff --git a/app/lib/app/database_error_app.dart b/app/lib/app/database_error_app.dart index cc1a9757..43fe66c9 100644 --- a/app/lib/app/database_error_app.dart +++ b/app/lib/app/database_error_app.dart @@ -1,14 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:fmp/app/app_material.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/settings/appearance_settings.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; /// 資料庫開不起來時的畫面(ADR 0010 §決定 3):App 不在半開的資料庫上啟動, /// 只顯示錯誤。重試、匯出診斷等選項隨舊資料匯入(M5)與 log(ADR 0025)再加。 -class DatabaseErrorApp extends StatelessWidget { +/// +/// 讀不到外觀設定,所以語言與主題都跟隨系統。 +class DatabaseErrorApp extends ConsumerWidget { const DatabaseErrorApp({ super.key, required this.flavor, required this.error, + required this.fontFallback, }); final AppFlavor flavor; @@ -16,22 +24,31 @@ class DatabaseErrorApp extends StatelessWidget { /// 開啟時拋出的錯誤,原樣顯示供回報問題。 final Object error; + /// 平台的 CJK 字型 fallback(`PlatformCapabilities.fontFallback`)。 + final FontFallback fontFallback; + @override - Widget build(BuildContext context) { - return MaterialApp( + Widget build(BuildContext context, WidgetRef ref) { + final locale = localeForSystem(ref.watch(systemLocalesProvider)); + final t = appLocaleOf(locale).buildSync(); + return fmpMaterialApp( title: flavor.displayName, - // 先寫死繁中;slang 在 M1 PR 12 接上後改用翻譯字串。 - home: Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '無法開啟資料庫', - style: Theme.of(context).textTheme.headlineMedium, - ), - SelectableText('$error'), - ], + locale: locale, + themeMode: ThemeMode.system, + fontFamilyFallback: fontFamilyFallbackOf(fontFallback, locale), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.startup.databaseError, + style: Theme.of(context).textTheme.headlineMedium, + ), + SelectableText('$error'), + ], + ), ), ), ), diff --git a/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart index 01e827f3..290d4d8e 100644 --- a/app/lib/app/fmp_app.dart +++ b/app/lib/app/fmp_app.dart @@ -1,28 +1,78 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:fmp/app/app_material.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/domain/appearance.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; import 'package:fmp/playback/dev_playback_entry.dart'; import 'package:fmp/playback/playback_providers.dart'; import 'package:fmp/playback/playback_state.dart'; import 'package:fmp/plugins/install/dev_plugin_entry.dart'; import 'package:fmp/plugins/plugin_registry.dart'; import 'package:fmp/plugins/source_plugin.dart'; +import 'package:fmp/settings/appearance_settings.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/settings/appearance_controls.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; -/// App 根元件。目前只顯示 App 名稱、flavor、資料目錄與載入的插件,供實機確認 -/// 身分與開發入口(插件、播放);正式的外殼在 M1 PR 12。 -class FmpApp extends StatelessWidget { +/// App 根元件:主題、介面語言與提示宿主(ADR 0023、0024)。 +/// +/// 畫面目前只有身分頁:App 名稱、flavor、資料目錄、外觀設定、字形樣本與載入的 +/// 插件,供實機確認身分、開發入口(插件、播放)與 CJK 字形;正式的外殼在 M1 +/// PR 12b。 +class FmpApp extends ConsumerStatefulWidget { const FmpApp({super.key, required this.flavor}); final AppFlavor flavor; + @override + ConsumerState createState() => _FmpAppState(); +} + +class _FmpAppState extends ConsumerState { + @override + void initState() { + super.initState(); + // 實機驗字形時對得上用的是哪個語言、哪些字型;不含個人資訊。字型清單由 + // 這次的語言算,不讀 fontFamilyFallbackProvider:它可能還沒收到通知。 + ref.listenManual(uiLocaleProvider, (_, locale) { + ref + .read(logProvider) + .info( + 'UI locale applied', + tag: 'ui', + fields: { + 'locale': flutterLocaleOf(locale).toLanguageTag(), + 'fontFallback': fontFamilyFallbackOf( + ref.read(platformCapabilitiesProvider).fontFallback, + locale, + ), + }, + ); + }, fireImmediately: true); + } + @override Widget build(BuildContext context) { - return MaterialApp( - title: flavor.displayName, - home: _IdentityPage(flavor: flavor), + final themeMode = ref.watch( + appearanceProvider.select((value) => value.value?.themeMode), + ); + return fmpMaterialApp( + title: widget.flavor.displayName, + locale: ref.watch(uiLocaleProvider), + themeMode: themeModeOf(themeMode ?? ThemeModeSetting.system), + fontFamilyFallback: ref.watch(fontFamilyFallbackProvider), + builder: (context, navigator) => + WindowClassScope(child: ToastHost(child: navigator!)), + home: _IdentityPage(flavor: widget.flavor), ); } } @@ -34,26 +84,82 @@ class _IdentityPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final spacing = AppTokens.of(context).spacing; return Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - flavor.displayName, - style: Theme.of(context).textTheme.headlineMedium, + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.x4), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + flavor.displayName, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text(flavor.name), + SelectableText(ref.watch(dataDirectoryProvider).path), + const _PluginList(), + const _DevPlayback(), + SizedBox(height: spacing.x4), + const _FontSample(), + SizedBox(height: spacing.x4), + const AppearanceControls(), + ], ), - Text(flavor.name), - SelectableText(ref.watch(dataDirectoryProvider).path), - const _PluginList(), - const _DevPlayback(), - ], + ), ), ), ); } } +/// 實機比對 CJK 字形用(M1 PR 12a):同一串字以介面的樣式、指定繁中、指定 +/// 簡中各顯示一行。 +/// +/// 指定的那兩行帶該語言的 locale 與平台的字型清單,是參照;第一行(介面實際 +/// 用的樣式)應該和介面語言那一行相同。挑的字在台灣與大陸的標準字形不同: +/// 「草」的艹、「骨」上半、「令」的末筆、「值」「角」「這」「說」。 +class _FontSample extends ConsumerWidget { + const _FontSample(); + + static const _sample = '草骨令值角這說'; + static const _traditional = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + ); + static const _simplified = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final style = Theme.of(context).textTheme.headlineSmall!; + final fonts = ref.watch(platformCapabilitiesProvider).fontFallback; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('UI $_sample', style: style), + Text( + 'zh-Hant $_sample', + style: style.copyWith( + locale: _traditional, + fontFamilyFallback: fonts.familiesFor(FontLanguage.zhTw), + ), + ), + Text( + 'zh-Hans $_sample', + style: style.copyWith( + locale: _simplified, + fontFamilyFallback: fonts.familiesFor(FontLanguage.zhCn), + ), + ), + ], + ); + } +} + /// 載入的插件(`id version`,沒有回應的加註),以及開發入口安裝失敗時的錯誤類別。 class _PluginList extends ConsumerWidget { const _PluginList(); diff --git a/app/lib/app/unsupported_platform_app.dart b/app/lib/app/unsupported_platform_app.dart index c29bccbb..a231a050 100644 --- a/app/lib/app/unsupported_platform_app.dart +++ b/app/lib/app/unsupported_platform_app.dart @@ -1,20 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:fmp/app/app_material.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/settings/appearance_settings.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; /// 平台沒有資料目錄實作時的畫面(Linux、macOS、iOS 驗證前,ADR 0009 -/// §決定 4)。不啟動資料層,只告訴使用者這個平台還不能用。 -class UnsupportedPlatformApp extends StatelessWidget { +/// §決定 4)。不啟動資料層,只告訴使用者這個平台還不能用;語言與主題跟隨 +/// 系統,也沒有字型 fallback(未驗證平台的能力宣告是「沒有」)。 +class UnsupportedPlatformApp extends ConsumerWidget { const UnsupportedPlatformApp({super.key, required this.flavor}); final AppFlavor flavor; @override - Widget build(BuildContext context) { - return MaterialApp( + Widget build(BuildContext context, WidgetRef ref) { + final locale = localeForSystem(ref.watch(systemLocalesProvider)); + return fmpMaterialApp( title: flavor.displayName, - // 先寫死繁中;slang 在 M1 PR 12 接上後改用翻譯字串。 - home: const Scaffold(body: Center(child: Text('此平台尚未支援'))), + locale: locale, + themeMode: ThemeMode.system, + fontFamilyFallback: const [], + home: Scaffold( + body: Center( + child: Text( + appLocaleOf(locale).buildSync().startup.unsupportedPlatform, + ), + ), + ), ); } } diff --git a/app/lib/core/errors/app_error.dart b/app/lib/core/errors/app_error.dart index 0ef9ba38..84893823 100644 --- a/app/lib/core/errors/app_error.dart +++ b/app/lib/core/errors/app_error.dart @@ -5,9 +5,10 @@ part 'report_error.dart'; /// 給使用者的錯誤訊息 key,ADR 0013 §決定 5 的類別表一列一個。 /// -/// 值的名稱就是 slang 的 key(PR 12 接上,放在 `errors.` 之下);改名等於改 -/// 翻譯檔的 key。「不支援」與「預期外」在類別表裡共用通用訊息,所以 -/// [Unsupported] 與 [UnexpectedError] 都預設 [unexpected]。 +/// 值的名稱就是 slang 的 key(`lib/i18n/*.i18n.json` 的 `errors.` 之下),由 +/// `lib/ui/errors/error_message.dart` 對到字串;改名等於改翻譯檔的 key。 +/// 「不支援」與「預期外」在類別表裡共用通用訊息,所以 [Unsupported] 與 +/// [UnexpectedError] 都預設 [unexpected]。 enum ErrorMessageKey { network, rateLimited, @@ -20,6 +21,13 @@ enum ErrorMessageKey { unexpected, } +/// i18n 訊息參數的名稱:封閉的清單,值一律是整數([AppError.messageArgs])。 +/// +/// 伺服器或插件給的文字沒有地方可放:音源名稱由呈現層以 [AppError.pluginId] +/// 查插件的顯示名稱,不經參數。M1 沒有訊息用到參數;第一個用到的訊息在呈現層 +/// 的對應函式裡讀它。 +enum ErrorMessageArg { count, seconds } + /// [Unavailable] 的原因(ADR 0013 §決定 1)。曲目上標示的就是它。 enum UnavailableReason { region, copyright, membership, age, previewOnly } @@ -75,9 +83,9 @@ sealed class AppError implements Exception { /// 使用者訊息的 i18n key。每個子類有預設值,音源可以覆寫。 final ErrorMessageKey messageKey; - /// i18n 訊息的參數。只放數字、enum 之類的值;伺服器或例外的原文放 - /// `cause`,不放這裡。 - final Map messageArgs; + /// i18n 訊息的參數:名稱是封閉的 [ErrorMessageArg],值只能是整數,型別上就 + /// 放不進伺服器或例外的原文(原文放 `cause`,只進 log)。 + final Map messageArgs; /// 預期內的錯誤(網路、限流、需登入等)為真;解析失敗、不支援與預期外視為 /// bug,為假。決定錯誤歷史的層級。 diff --git a/app/lib/i18n/en.i18n.json b/app/lib/i18n/en.i18n.json new file mode 100644 index 00000000..a060f04c --- /dev/null +++ b/app/lib/i18n/en.i18n.json @@ -0,0 +1,34 @@ +{ + "startup": { + "databaseError": "Can't open the database", + "unsupportedPlatform": "This platform isn't supported yet" + }, + "appearance": { + "theme": "Theme", + "themeSystem": "System", + "themeLight": "Light", + "themeDark": "Dark", + "language": "Language", + "languageSystem": "System default" + }, + "errors": { + "network": "Network connection failed. Check your connection and try again.", + "rateLimited": "Too many requests to {source}. Try again later.", + "authRequired": "Sign in to {source} to continue.", + "credentialInvalid": "Your sign-in to {source} has expired. Sign in again.", + "verificationRequired": "Verification required by {source}. Sign in, paste a cookie, or try again later.", + "unavailable": "This isn't available.", + "unavailableBecause": "Not available: {reason}", + "notFound": "Not found. It may have been removed.", + "parseError": "The response format of {source} changed. An update may be needed.", + "unexpected": "Something went wrong.", + "unknownSource": "the source", + "unavailableReasons": { + "region": "not offered in your region", + "copyright": "copyright restriction", + "membership": "membership required", + "age": "age restricted", + "previewOnly": "preview only" + } + } +} diff --git a/app/lib/i18n/strings.g.dart b/app/lib/i18n/strings.g.dart new file mode 100644 index 00000000..9b3deadb --- /dev/null +++ b/app/lib/i18n/strings.g.dart @@ -0,0 +1,106 @@ +/// Generated file. Do not edit. +/// +/// Source: lib/i18n +/// To regenerate, run: `dart run slang` +/// +/// Locales: 3 +/// Strings: 72 (24 per locale) + +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'package:slang/slang.dart'; +export 'package:slang/slang.dart'; + +import 'strings_en.g.dart' as l_en; +import 'strings_zh_CN.g.dart' as l_zh_CN; +part 'strings_zh_TW.g.dart'; + +/// Supported locales. +/// +/// Usage: +/// - LocaleSettings.setLocale(AppLocale.zhTw) // set locale +/// - Locale locale = AppLocale.zhTw.flutterLocale // get flutter locale from enum +/// - if (LocaleSettings.currentLocale == AppLocale.zhTw) // locale check +enum AppLocale with BaseAppLocale { + zhTw(languageCode: 'zh', countryCode: 'TW'), + en(languageCode: 'en'), + zhCn(languageCode: 'zh', countryCode: 'CN'); + + const AppLocale({ + required this.languageCode, + this.scriptCode, // ignore: unused_element, unused_element_parameter + this.countryCode, // ignore: unused_element, unused_element_parameter + }); + + @override + final String languageCode; + @override + final String? scriptCode; + @override + final String? countryCode; + + @override + Future build({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) async { + return buildSync( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + + @override + Translations buildSync({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) { + switch (this) { + case AppLocale.zhTw: + return TranslationsZhTw( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.en: + return l_en.TranslationsEn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.zhCn: + return l_zh_CN.TranslationsZhCn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + } +} + +/// Provides utility functions without any side effects. +class AppLocaleUtils extends BaseAppLocaleUtils { + AppLocaleUtils._() + : super(baseLocale: AppLocale.zhTw, locales: AppLocale.values); + + static final instance = AppLocaleUtils._(); + + // static aliases (checkout base methods for documentation) + static AppLocale parse(String rawLocale) => instance.parse(rawLocale); + static AppLocale parseLocaleParts({ + required String languageCode, + String? scriptCode, + String? countryCode, + }) => instance.parseLocaleParts( + languageCode: languageCode, + scriptCode: scriptCode, + countryCode: countryCode, + ); + static List get supportedLocalesRaw => instance.supportedLocalesRaw; +} diff --git a/app/lib/i18n/strings_en.g.dart b/app/lib/i18n/strings_en.g.dart new file mode 100644 index 00000000..df8fd0ef --- /dev/null +++ b/app/lib/i18n/strings_en.g.dart @@ -0,0 +1,162 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; + +import 'strings.g.dart'; + +// Path: +class TranslationsEn extends Translations + with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsEn({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + TranslationMetadata? meta, + }) : assert( + overrides == null, + 'Set "translation_overrides: true" in order to enable this feature.', + ), + _meta = + meta ?? + TranslationMetadata( + locale: AppLocale.en, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super( + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + + /// Metadata for the translations of . + final TranslationMetadata _meta; + @override + TranslationMetadata get $meta => _meta; + + late final TranslationsEn _root = this; // ignore: unused_field + + @override + TranslationsEn $copyWith({ + TranslationMetadata? meta, + }) => TranslationsEn(meta: meta ?? this.$meta); + + // Translations + @override + late final Translations$startup$en startup = Translations$startup$en._(_root); + @override + late final Translations$appearance$en appearance = + Translations$appearance$en._(_root); + @override + late final Translations$errors$en errors = Translations$errors$en._(_root); +} + +// Path: startup +class Translations$startup$en extends Translations$startup$zh_TW { + Translations$startup$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get databaseError => 'Can\'t open the database'; + @override + String get unsupportedPlatform => 'This platform isn\'t supported yet'; +} + +// Path: appearance +class Translations$appearance$en extends Translations$appearance$zh_TW { + Translations$appearance$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get theme => 'Theme'; + @override + String get themeSystem => 'System'; + @override + String get themeLight => 'Light'; + @override + String get themeDark => 'Dark'; + @override + String get language => 'Language'; + @override + String get languageSystem => 'System default'; +} + +// Path: errors +class Translations$errors$en extends Translations$errors$zh_TW { + Translations$errors$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get network => + 'Network connection failed. Check your connection and try again.'; + @override + String rateLimited({required Object source}) => + 'Too many requests to ${source}. Try again later.'; + @override + String authRequired({required Object source}) => + 'Sign in to ${source} to continue.'; + @override + String credentialInvalid({required Object source}) => + 'Your sign-in to ${source} has expired. Sign in again.'; + @override + String verificationRequired({required Object source}) => + 'Verification required by ${source}. Sign in, paste a cookie, or try again later.'; + @override + String get unavailable => 'This isn\'t available.'; + @override + String unavailableBecause({required Object reason}) => + 'Not available: ${reason}'; + @override + String get notFound => 'Not found. It may have been removed.'; + @override + String parseError({required Object source}) => + 'The response format of ${source} changed. An update may be needed.'; + @override + String get unexpected => 'Something went wrong.'; + @override + String get unknownSource => 'the source'; + @override + late final Translations$errors$unavailableReasons$en unavailableReasons = + Translations$errors$unavailableReasons$en._(_root); +} + +// Path: errors.unavailableReasons +class Translations$errors$unavailableReasons$en + extends Translations$errors$unavailableReasons$zh_TW { + Translations$errors$unavailableReasons$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get region => 'not offered in your region'; + @override + String get copyright => 'copyright restriction'; + @override + String get membership => 'membership required'; + @override + String get age => 'age restricted'; + @override + String get previewOnly => 'preview only'; +} diff --git a/app/lib/i18n/strings_zh_CN.g.dart b/app/lib/i18n/strings_zh_CN.g.dart new file mode 100644 index 00000000..7109c63a --- /dev/null +++ b/app/lib/i18n/strings_zh_CN.g.dart @@ -0,0 +1,159 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; + +import 'strings.g.dart'; + +// Path: +class TranslationsZhCn extends Translations + with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsZhCn({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + TranslationMetadata? meta, + }) : assert( + overrides == null, + 'Set "translation_overrides: true" in order to enable this feature.', + ), + _meta = + meta ?? + TranslationMetadata( + locale: AppLocale.zhCn, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super( + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + + /// Metadata for the translations of . + final TranslationMetadata _meta; + @override + TranslationMetadata get $meta => _meta; + + late final TranslationsZhCn _root = this; // ignore: unused_field + + @override + TranslationsZhCn $copyWith({ + TranslationMetadata? meta, + }) => TranslationsZhCn(meta: meta ?? this.$meta); + + // Translations + @override + late final Translations$startup$zh_CN startup = + Translations$startup$zh_CN.internal(_root); + @override + late final Translations$appearance$zh_CN appearance = + Translations$appearance$zh_CN.internal(_root); + @override + late final Translations$errors$zh_CN errors = + Translations$errors$zh_CN.internal(_root); +} + +// Path: startup +class Translations$startup$zh_CN extends Translations$startup$zh_TW { + Translations$startup$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get databaseError => '无法打开数据库'; + @override + String get unsupportedPlatform => '暂不支持此平台'; +} + +// Path: appearance +class Translations$appearance$zh_CN extends Translations$appearance$zh_TW { + Translations$appearance$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get theme => '主题'; + @override + String get themeSystem => '跟随系统'; + @override + String get themeLight => '浅色'; + @override + String get themeDark => '深色'; + @override + String get language => '语言'; + @override + String get languageSystem => '跟随系统'; +} + +// Path: errors +class Translations$errors$zh_CN extends Translations$errors$zh_TW { + Translations$errors$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get network => '网络连接失败,请检查网络后重试'; + @override + String rateLimited({required Object source}) => '${source} 请求过于频繁,请稍后再试'; + @override + String authRequired({required Object source}) => '需要登录 ${source}'; + @override + String credentialInvalid({required Object source}) => + '${source} 的登录已失效,请重新登录'; + @override + String verificationRequired({required Object source}) => + '${source} 要求验证:请登录、粘贴 Cookie 或稍后再试'; + @override + String get unavailable => '无法获取此内容'; + @override + String unavailableBecause({required Object reason}) => '无法获取:${reason}'; + @override + String get notFound => '找不到内容,可能已失效'; + @override + String parseError({required Object source}) => '${source} 的响应格式已变化,可能需要更新'; + @override + String get unexpected => '发生意外错误'; + @override + String get unknownSource => '音源'; + @override + late final Translations$errors$unavailableReasons$zh_CN unavailableReasons = + Translations$errors$unavailableReasons$zh_CN.internal(_root); +} + +// Path: errors.unavailableReasons +class Translations$errors$unavailableReasons$zh_CN + extends Translations$errors$unavailableReasons$zh_TW { + Translations$errors$unavailableReasons$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get region => '所在地区不可用'; + @override + String get copyright => '版权限制'; + @override + String get membership => '需要会员'; + @override + String get age => '有年龄限制'; + @override + String get previewOnly => '仅可试听片段'; +} diff --git a/app/lib/i18n/strings_zh_TW.g.dart b/app/lib/i18n/strings_zh_TW.g.dart new file mode 100644 index 00000000..4a8beff2 --- /dev/null +++ b/app/lib/i18n/strings_zh_TW.g.dart @@ -0,0 +1,164 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +part of 'strings.g.dart'; + +// Path: +typedef TranslationsZhTw = Translations; // ignore: unused_element + +class Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + Translations({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + TranslationMetadata? meta, + }) : assert( + overrides == null, + 'Set "translation_overrides: true" in order to enable this feature.', + ), + _meta = + meta ?? + TranslationMetadata( + locale: AppLocale.zhTw, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + + /// Metadata for the translations of . + final TranslationMetadata _meta; + @override + TranslationMetadata get $meta => _meta; + + late final Translations _root = this; // ignore: unused_field + + Translations $copyWith({ + TranslationMetadata? meta, + }) => Translations(meta: meta ?? this.$meta); + + // Translations + late final Translations$startup$zh_TW startup = + Translations$startup$zh_TW.internal(_root); + late final Translations$appearance$zh_TW appearance = + Translations$appearance$zh_TW.internal(_root); + late final Translations$errors$zh_TW errors = + Translations$errors$zh_TW.internal(_root); +} + +// Path: startup +class Translations$startup$zh_TW { + Translations$startup$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '無法開啟資料庫' + String get databaseError => '無法開啟資料庫'; + + /// zh-TW: '此平台尚未支援' + String get unsupportedPlatform => '此平台尚未支援'; +} + +// Path: appearance +class Translations$appearance$zh_TW { + Translations$appearance$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '主題' + String get theme => '主題'; + + /// zh-TW: '跟隨系統' + String get themeSystem => '跟隨系統'; + + /// zh-TW: '淺色' + String get themeLight => '淺色'; + + /// zh-TW: '深色' + String get themeDark => '深色'; + + /// zh-TW: '語言' + String get language => '語言'; + + /// zh-TW: '跟隨系統' + String get languageSystem => '跟隨系統'; +} + +// Path: errors +class Translations$errors$zh_TW { + Translations$errors$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '網路連線失敗,請檢查網路後再試' + String get network => '網路連線失敗,請檢查網路後再試'; + + /// zh-TW: '{source} 請求太頻繁,請稍後再試' + String rateLimited({required Object source}) => '${source} 請求太頻繁,請稍後再試'; + + /// zh-TW: '需要登入 {source}' + String authRequired({required Object source}) => '需要登入 ${source}'; + + /// zh-TW: '{source} 的登入已失效,請重新登入' + String credentialInvalid({required Object source}) => + '${source} 的登入已失效,請重新登入'; + + /// zh-TW: '{source} 要求驗證:請登入、貼上 cookie 或稍後再試' + String verificationRequired({required Object source}) => + '${source} 要求驗證:請登入、貼上 cookie 或稍後再試'; + + /// zh-TW: '無法取得這個內容' + String get unavailable => '無法取得這個內容'; + + /// zh-TW: '無法取得:{reason}' + String unavailableBecause({required Object reason}) => '無法取得:${reason}'; + + /// zh-TW: '找不到內容,可能已失效' + String get notFound => '找不到內容,可能已失效'; + + /// zh-TW: '{source} 的回應格式改變,可能需要更新' + String parseError({required Object source}) => '${source} 的回應格式改變,可能需要更新'; + + /// zh-TW: '發生預期外的錯誤' + String get unexpected => '發生預期外的錯誤'; + + /// zh-TW: '音源' + String get unknownSource => '音源'; + + late final Translations$errors$unavailableReasons$zh_TW unavailableReasons = + Translations$errors$unavailableReasons$zh_TW.internal(_root); +} + +// Path: errors.unavailableReasons +class Translations$errors$unavailableReasons$zh_TW { + Translations$errors$unavailableReasons$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '所在地區不提供' + String get region => '所在地區不提供'; + + /// zh-TW: '版權限制' + String get copyright => '版權限制'; + + /// zh-TW: '需要會員' + String get membership => '需要會員'; + + /// zh-TW: '有年齡限制' + String get age => '有年齡限制'; + + /// zh-TW: '只有試聽片段' + String get previewOnly => '只有試聽片段'; +} diff --git a/app/lib/i18n/zh-CN.i18n.json b/app/lib/i18n/zh-CN.i18n.json new file mode 100644 index 00000000..31e3dec9 --- /dev/null +++ b/app/lib/i18n/zh-CN.i18n.json @@ -0,0 +1,34 @@ +{ + "startup": { + "databaseError": "无法打开数据库", + "unsupportedPlatform": "暂不支持此平台" + }, + "appearance": { + "theme": "主题", + "themeSystem": "跟随系统", + "themeLight": "浅色", + "themeDark": "深色", + "language": "语言", + "languageSystem": "跟随系统" + }, + "errors": { + "network": "网络连接失败,请检查网络后重试", + "rateLimited": "{source} 请求过于频繁,请稍后再试", + "authRequired": "需要登录 {source}", + "credentialInvalid": "{source} 的登录已失效,请重新登录", + "verificationRequired": "{source} 要求验证:请登录、粘贴 Cookie 或稍后再试", + "unavailable": "无法获取此内容", + "unavailableBecause": "无法获取:{reason}", + "notFound": "找不到内容,可能已失效", + "parseError": "{source} 的响应格式已变化,可能需要更新", + "unexpected": "发生意外错误", + "unknownSource": "音源", + "unavailableReasons": { + "region": "所在地区不可用", + "copyright": "版权限制", + "membership": "需要会员", + "age": "有年龄限制", + "previewOnly": "仅可试听片段" + } + } +} diff --git a/app/lib/i18n/zh-TW.i18n.json b/app/lib/i18n/zh-TW.i18n.json new file mode 100644 index 00000000..8e0b8dc3 --- /dev/null +++ b/app/lib/i18n/zh-TW.i18n.json @@ -0,0 +1,34 @@ +{ + "startup": { + "databaseError": "無法開啟資料庫", + "unsupportedPlatform": "此平台尚未支援" + }, + "appearance": { + "theme": "主題", + "themeSystem": "跟隨系統", + "themeLight": "淺色", + "themeDark": "深色", + "language": "語言", + "languageSystem": "跟隨系統" + }, + "errors": { + "network": "網路連線失敗,請檢查網路後再試", + "rateLimited": "{source} 請求太頻繁,請稍後再試", + "authRequired": "需要登入 {source}", + "credentialInvalid": "{source} 的登入已失效,請重新登入", + "verificationRequired": "{source} 要求驗證:請登入、貼上 cookie 或稍後再試", + "unavailable": "無法取得這個內容", + "unavailableBecause": "無法取得:{reason}", + "notFound": "找不到內容,可能已失效", + "parseError": "{source} 的回應格式改變,可能需要更新", + "unexpected": "發生預期外的錯誤", + "unknownSource": "音源", + "unavailableReasons": { + "region": "所在地區不提供", + "copyright": "版權限制", + "membership": "需要會員", + "age": "有年齡限制", + "previewOnly": "只有試聽片段" + } + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 05a6e24e..deb41c7f 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -69,7 +69,11 @@ Future main(List arguments) async { ); runApp( appProviderScope( - child: DatabaseErrorApp(flavor: flavor, error: error), + child: DatabaseErrorApp( + flavor: flavor, + error: error, + fontFallback: platform.capabilities.fontFallback, + ), ), ); return; diff --git a/app/lib/ui/errors/error_message.dart b/app/lib/ui/errors/error_message.dart new file mode 100644 index 00000000..221e3d74 --- /dev/null +++ b/app/lib/ui/errors/error_message.dart @@ -0,0 +1,45 @@ +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/i18n/strings.g.dart'; + +/// [error] 給使用者看的訊息(ADR 0013 §決定 5 的類別表)。 +/// +/// 文字只來自翻譯檔:`messageKey` 對到 `errors.` 之下同名的字串,音源名稱是 +/// [sourceName](呈現層以 `pluginId` 查插件的顯示名稱),沒有就用通用的 +/// 「音源」。`AppError` 沒有別的字串可以放進來(`messageArgs` 只收整數,M1 沒有 +/// 訊息用到)。 +String errorMessage(Translations t, AppError error, {String? sourceName}) { + final errors = t.errors; + final source = sourceName ?? errors.unknownSource; + return switch (error.messageKey) { + ErrorMessageKey.network => errors.network, + ErrorMessageKey.rateLimited => errors.rateLimited(source: source), + ErrorMessageKey.authRequired => errors.authRequired(source: source), + ErrorMessageKey.credentialInvalid => errors.credentialInvalid( + source: source, + ), + ErrorMessageKey.verificationRequired => errors.verificationRequired( + source: source, + ), + ErrorMessageKey.unavailable => switch (error) { + Unavailable(:final reason) => errors.unavailableBecause( + reason: unavailableReasonText(t, reason), + ), + _ => errors.unavailable, + }, + ErrorMessageKey.notFound => errors.notFound, + ErrorMessageKey.parseError => errors.parseError(source: source), + ErrorMessageKey.unexpected => errors.unexpected, + }; +} + +/// [Unavailable] 的原因;曲目上標示的也是它。 +String unavailableReasonText(Translations t, UnavailableReason reason) { + final reasons = t.errors.unavailableReasons; + return switch (reason) { + UnavailableReason.region => reasons.region, + UnavailableReason.copyright => reasons.copyright, + UnavailableReason.membership => reasons.membership, + UnavailableReason.age => reasons.age, + UnavailableReason.previewOnly => reasons.previewOnly, + }; +} diff --git a/app/lib/ui/i18n/ui_locale.dart b/app/lib/ui/i18n/ui_locale.dart new file mode 100644 index 00000000..c4adc16a --- /dev/null +++ b/app/lib/ui/i18n/ui_locale.dart @@ -0,0 +1,98 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; +import 'package:fmp/settings/appearance_settings.dart'; + +// 介面語言的各種表示(Flutter、slang、字型)全部從 LocaleSetting 對出來, +// 只在這個檔案對(ADR 0024 §決定 7)。 + +/// 給 `MaterialApp.locale` 的 Flutter locale:中文一律帶書寫系統。 +/// +/// 文字的 locale 決定 CJK 字形(Android 依它在系統的 Noto CJK 裡挑繁或簡), +/// Material 的內建字串也依書寫系統選 `zh_Hant_TW`/`zh_Hans`(`material_ui` 的 +/// `getMaterialTranslation`)。slang 的 locale 是 `zh-TW`/`zh-CN`(ADR 的 +/// `base_locale: zh-TW`),兩者在這裡對應。 +Locale flutterLocaleOf(LocaleSetting locale) => switch (locale) { + LocaleSetting.zhTw => const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + countryCode: 'TW', + ), + LocaleSetting.zhCn => const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + countryCode: 'CN', + ), + LocaleSetting.en => const Locale('en'), +}; + +/// 主題文字樣式的 locale(`buildAppTheme` 的 `textLocale`):決定漢字的繁簡 +/// 字形。中文介面就是 [flutterLocaleOf];英文介面照 ADR 0024 §決定 2 用繁中, +/// 英文字不受影響。 +Locale textLocaleOf(LocaleSetting locale) => flutterLocaleOf(switch (locale) { + LocaleSetting.en => LocaleSetting.zhTw, + LocaleSetting.zhTw || LocaleSetting.zhCn => locale, +}); + +/// `MaterialApp.supportedLocales`:三種語言的 [flutterLocaleOf]。`locale` 一律 +/// 明確給定,所以系統語言對到哪一種由 `localeForSystem` 決定,不經 Flutter 的 +/// locale 解析。 +final supportedFlutterLocales = [ + for (final locale in LocaleSetting.values) flutterLocaleOf(locale), +]; + +/// slang 的語言。 +AppLocale appLocaleOf(LocaleSetting locale) => switch (locale) { + LocaleSetting.zhTw => AppLocale.zhTw, + LocaleSetting.zhCn => AppLocale.zhCn, + LocaleSetting.en => AppLocale.en, +}; + +/// 字型 fallback 的語言(平台層的 `FontFallback.familiesFor`)。 +FontLanguage fontLanguageOf(LocaleSetting locale) => switch (locale) { + LocaleSetting.zhTw => FontLanguage.zhTw, + LocaleSetting.zhCn => FontLanguage.zhCn, + LocaleSetting.en => FontLanguage.en, +}; + +/// 語言選單上的名稱:每個語言用它自己的寫法,不隨介面語言翻譯(Android、 +/// iOS 語言設定的慣例),所以不放翻譯檔。 +String localeEndonym(LocaleSetting locale) => switch (locale) { + LocaleSetting.zhTw => '繁體中文', + LocaleSetting.zhCn => '简体中文', + LocaleSetting.en => 'English', +}; + +/// 目前的介面語言:外觀設定的生效值;設定還沒讀出來時先跟隨系統,不留空白的 +/// 第一幀。 +final uiLocaleProvider = Provider( + (ref) => + ref.watch(appearanceProvider.select((value) => value.value?.locale)) ?? + localeForSystem(ref.watch(systemLocalesProvider)), +); + +/// 目前介面語言的翻譯。沒有 slang 的全域 `t`:widget 以 +/// `ref.watch(translationsProvider)` 取,`Toaster` 以 `ref.read` 取。 +final translationsProvider = Provider( + (ref) => appLocaleOf(ref.watch(uiLocaleProvider)).buildSync(), +); + +/// [locale] 的 CJK 字型 fallback(ADR 0024 §決定 2)。 +List fontFamilyFallbackOf(FontFallback fonts, LocaleSetting locale) => + fonts.familiesFor(fontLanguageOf(locale)); + +/// 目前介面語言的 CJK 字型 fallback。 +/// +/// 在 [uiLocaleProvider] 的 listener 裡不要 `ref.read` 它:listener 可能比它先 +/// 收到通知,讀到的是上一個語言的清單;改以 listener 拿到的語言呼叫 +/// [fontFamilyFallbackOf]。 +final fontFamilyFallbackProvider = Provider>( + (ref) => fontFamilyFallbackOf( + ref.watch(platformCapabilitiesProvider).fontFallback, + ref.watch(uiLocaleProvider), + ), +); diff --git a/app/lib/ui/layout/window_class.dart b/app/lib/ui/layout/window_class.dart new file mode 100644 index 00000000..1a01732d --- /dev/null +++ b/app/lib/ui/layout/window_class.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; + +/// 寬度等級(ADR 0024 §決定 3),與 M3 window size class 同值: +/// compact < 600 ≤ medium < 840 ≤ expanded < 1200 ≤ large < 1600 ≤ extraLarge。 +/// +/// 看的是內容區的寬度,不是螢幕:由 [WindowClassScope] 量它所在的位置,子樹以 +/// [WindowClass.of] 讀。 +enum WindowClass { + compact, + medium, + expanded, + large, + extraLarge; + + /// [width](dp)屬於哪一級;下限含在該級內(600 是 medium)。 + static WindowClass forWidth(double width) { + if (width < 600) return compact; + if (width < 840) return medium; + if (width < 1200) return expanded; + if (width < 1600) return large; + return extraLarge; + } + + /// 最近的 [WindowClassScope] 量到的等級;只在等級改變時重建。 + static WindowClass of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_WindowClassInherited>(); + assert(scope != null, 'No WindowClassScope above this context'); + return scope!.windowClass; + } +} + +/// 以自己拿到的寬度決定 [WindowClass],提供給子樹。 +/// +/// App 根(`MaterialApp.builder`)放一個,量的是整個視窗;外殼在內容區再放 +/// 一個,頁面讀到的就是內容區的等級。 +class WindowClassScope extends StatelessWidget { + const WindowClassScope({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => _WindowClassInherited( + windowClass: WindowClass.forWidth(constraints.maxWidth), + child: child, + ), + ); +} + +class _WindowClassInherited extends InheritedWidget { + const _WindowClassInherited({ + required this.windowClass, + required super.child, + }); + + final WindowClass windowClass; + + @override + bool updateShouldNotify(_WindowClassInherited oldWidget) => + oldWidget.windowClass != windowClass; +} diff --git a/app/lib/ui/settings/appearance_controls.dart b/app/lib/ui/settings/appearance_controls.dart new file mode 100644 index 00000000..09418ec0 --- /dev/null +++ b/app/lib/ui/settings/appearance_controls.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/settings/appearance_settings.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 外觀設定的主題與語言(ADR 0011 §決定 7)。「跟隨系統」寫回 `null`(沒設定 +/// 過),不是存一個值。 +/// +/// M1 放在身分頁供實機切換;12b 的設定頁沿用。 +class AppearanceControls extends ConsumerWidget { + const AppearanceControls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stored = ref.watch(appearanceProvider).value?.stored; + if (stored == null) return const SizedBox.shrink(); + final t = ref.watch(translationsProvider).appearance; + final notifier = ref.read(appearanceProvider.notifier); + final spacing = AppTokens.of(context).spacing; + final textTheme = Theme.of(context).textTheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.theme, style: textTheme.titleSmall), + SizedBox(height: spacing.x2), + // 舊資料或之後的設定頁可能存了明確的 system;兩者都顯示為跟隨系統, + // 選它一律清回 null。 + SegmentedButton( + segments: [ + ButtonSegment( + value: ThemeModeSetting.system, + label: Text(t.themeSystem), + ), + ButtonSegment( + value: ThemeModeSetting.light, + label: Text(t.themeLight), + ), + ButtonSegment( + value: ThemeModeSetting.dark, + label: Text(t.themeDark), + ), + ], + selected: {stored.themeMode ?? ThemeModeSetting.system}, + onSelectionChanged: (selection) => unawaited( + notifier.setThemeMode(switch (selection.single) { + ThemeModeSetting.system => null, + final mode => mode, + }), + ), + ), + SizedBox(height: spacing.x4), + Text(t.language, style: textTheme.titleSmall), + // 值 null 是「跟隨系統」。 + RadioGroup( + groupValue: stored.locale, + onChanged: (locale) => unawaited(notifier.setLocale(locale)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: null, + title: Text(t.languageSystem), + ), + for (final locale in LocaleSetting.values) + RadioListTile( + value: locale, + // 名稱帶自己的 locale,繁簡中文的字形不隨介面語言變。寫在 + // 樣式上:主題的樣式帶介面的 locale,會蓋過 Text.locale。 + title: Text( + localeEndonym(locale), + style: TextStyle(locale: flutterLocaleOf(locale)), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/ui/theme/app_layout.dart b/app/lib/ui/theme/app_layout.dart new file mode 100644 index 00000000..cfec9ae3 --- /dev/null +++ b/app/lib/ui/theme/app_layout.dart @@ -0,0 +1,6 @@ +/// 元件的固定尺寸(ADR 0024 §決定 1)。和 `AppTokens` 的間距不同,這些是某個 +/// 元件自己的上限或寬度,隨元件出現時加。 +abstract final class AppLayout { + /// 桌面上提示(Toast)的最大寬度(ADR 0023 §決定 2)。 + static const double toastMaxWidth = 560; +} diff --git a/app/lib/ui/theme/app_theme.dart b/app/lib/ui/theme/app_theme.dart new file mode 100644 index 00000000..4c6ac186 --- /dev/null +++ b/app/lib/ui/theme/app_theme.dart @@ -0,0 +1,69 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// App 的 seed 色:沿用舊版的預設(M3 baseline 的紫)。preset 與自訂色在設定頁 +/// 做外觀設定時再加(ADR 0024 §決定 1)。 +const appSeedColor = Color(0xFF6750A4); + +/// 淺色或深色主題(ADR 0024 §決定 1):M3 元件、[appSeedColor] 產生的 +/// `ColorScheme`、[AppTokens]。 +/// +/// [fontFamilyFallback] 是目前介面語言的 CJK 字型清單,由平台層提供 +/// (`PlatformCapabilities.fontFallback`,ADR 0024 §決定 2);空清單表示交給 +/// 引擎依文字的 locale 挑(Android)。 +/// +/// [textLocale] 放進每個文字樣式,決定漢字用繁中或簡中字形:不指名字型的平台 +/// 只看它。英文介面的 `MaterialApp.locale` 是 `en`,漢字會落到系統的第一個 CJK +/// 字型(Android 是簡中),所以樣式另外帶中文的 locale(`textLocaleOf`)。樣式 +/// 的 locale 蓋過 `Text.locale`,要顯示另一種字形的文字改在樣式上指定。 +ThemeData buildAppTheme( + Brightness brightness, { + required List fontFamilyFallback, + required Locale textLocale, +}) { + final scheme = ColorScheme.fromSeed( + seedColor: appSeedColor, + brightness: brightness, + ); + final localized = _textThemeWithLocale(textLocale); + return ThemeData( + colorScheme: scheme, + fontFamilyFallback: fontFamilyFallback.isEmpty ? null : fontFamilyFallback, + // ThemeData 把它合併進預設的字級,只加 locale。 + textTheme: localized, + primaryTextTheme: localized, + extensions: [AppTokens.forScheme(scheme)], + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ); +} + +/// 每個角色只帶 [locale] 的 `TextTheme`。 +TextTheme _textThemeWithLocale(Locale locale) { + final style = TextStyle(locale: locale); + return TextTheme( + displayLarge: style, + displayMedium: style, + displaySmall: style, + headlineLarge: style, + headlineMedium: style, + headlineSmall: style, + titleLarge: style, + titleMedium: style, + titleSmall: style, + bodyLarge: style, + bodyMedium: style, + bodySmall: style, + labelLarge: style, + labelMedium: style, + labelSmall: style, + ); +} + +/// 外觀設定的主題模式對到 Flutter 的。 +ThemeMode themeModeOf(ThemeModeSetting setting) => switch (setting) { + ThemeModeSetting.system => ThemeMode.system, + ThemeModeSetting.light => ThemeMode.light, + ThemeModeSetting.dark => ThemeMode.dark, +}; diff --git a/app/lib/ui/theme/app_tokens.dart b/app/lib/ui/theme/app_tokens.dart new file mode 100644 index 00000000..e6c14e8e --- /dev/null +++ b/app/lib/ui/theme/app_tokens.dart @@ -0,0 +1,157 @@ +import 'package:material_ui/material_ui.dart'; + +/// 設計 token(ADR 0024 §決定 1):間距、圓角、語意色、焦點框。 +/// +/// `lib/ui/`(本目錄除外)的間距、圓角、顏色只從這裡、`ColorScheme` 與 +/// [AppLayout](`app_layout.dart`)取,lint `fmp_design_tokens` 擋數字字面值與 +/// `Colors.*`。字級只用 `TextTheme` 的 M3 角色,這裡沒有字級。 +/// +/// 取用:`AppTokens.of(context)`。間距與圓角不隨主題變,放在 extension 裡是為了 +/// 只有一個入口。 +@immutable +final class AppTokens extends ThemeExtension { + const AppTokens({ + required this.success, + required this.warning, + required this.focusRingColor, + }); + + /// 由主題的 [ColorScheme] 推出:語意色以固定的 seed 產生同亮度的 M3 色調 + /// (`ColorScheme.fromSeed` 的 primary 那一組),焦點框用 `primary`。 + factory AppTokens.forScheme(ColorScheme scheme) => AppTokens( + success: SemanticColors._fromSeed(_successSeed, scheme.brightness), + warning: SemanticColors._fromSeed(_warningSeed, scheme.brightness), + focusRingColor: scheme.primary, + ); + + /// 目前主題的 token。主題由 `buildAppTheme` 建立,一定帶著它。 + static AppTokens of(BuildContext context) => + Theme.of(context).extension()!; + + /// 成功的綠、警告的琥珀;只當 seed,實際顏色是 M3 依亮度算出的色調。 + static const _successSeed = Color(0xFF2E7D32); + static const _warningSeed = Color(0xFFF9A825); + + /// 間距:4dp 的倍數,名稱是倍數(`x4` = 16dp)。 + AppSpacing get spacing => const AppSpacing(); + + /// 圓角:M3 shape scale。 + AppRadius get radius => const AppRadius(); + + final SemanticColors success; + final SemanticColors warning; + + /// 焦點框:[focusRingWidth] 寬的 `primary`,向外擴 [focusRingOffset]。 + final Color focusRingColor; + double get focusRingWidth => 2; + double get focusRingOffset => 2; + + @override + AppTokens copyWith({ + SemanticColors? success, + SemanticColors? warning, + Color? focusRingColor, + }) => AppTokens( + success: success ?? this.success, + warning: warning ?? this.warning, + focusRingColor: focusRingColor ?? this.focusRingColor, + ); + + @override + AppTokens lerp(AppTokens? other, double t) { + if (other == null) return this; + return AppTokens( + success: SemanticColors.lerp(success, other.success, t), + warning: SemanticColors.lerp(warning, other.warning, t), + focusRingColor: Color.lerp(focusRingColor, other.focusRingColor, t)!, + ); + } + + @override + bool operator ==(Object other) => + other is AppTokens && + other.success == success && + other.warning == warning && + other.focusRingColor == focusRingColor; + + @override + int get hashCode => Object.hash(success, warning, focusRingColor); +} + +/// 間距(dp)。ADR 0024 §決定 1 的九個值。 +@immutable +final class AppSpacing { + const AppSpacing(); + + double get x1 => 4; + double get x2 => 8; + double get x3 => 12; + double get x4 => 16; + double get x5 => 20; + double get x6 => 24; + double get x8 => 32; + double get x10 => 40; + double get x12 => 48; +} + +/// 圓角半徑(dp),M3 shape scale 的名稱。 +@immutable +final class AppRadius { + const AppRadius(); + + double get extraSmall => 4; + double get small => 8; + double get medium => 12; + double get large => 16; + double get extraLarge => 28; +} + +/// 一個語意色的四個色調,對應 M3 的 primary 那一組:[color]/[onColor] 給 +/// 圖示與強調,[container]/[onContainer] 給底色與上面的文字。成對使用, +/// 對比度由 M3 的色調差保證。 +@immutable +final class SemanticColors { + const SemanticColors({ + required this.color, + required this.onColor, + required this.container, + required this.onContainer, + }); + + factory SemanticColors._fromSeed(Color seed, Brightness brightness) { + final scheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: brightness, + ); + return SemanticColors( + color: scheme.primary, + onColor: scheme.onPrimary, + container: scheme.primaryContainer, + onContainer: scheme.onPrimaryContainer, + ); + } + + final Color color; + final Color onColor; + final Color container; + final Color onContainer; + + static SemanticColors lerp(SemanticColors a, SemanticColors b, double t) => + SemanticColors( + color: Color.lerp(a.color, b.color, t)!, + onColor: Color.lerp(a.onColor, b.onColor, t)!, + container: Color.lerp(a.container, b.container, t)!, + onContainer: Color.lerp(a.onContainer, b.onContainer, t)!, + ); + + @override + bool operator ==(Object other) => + other is SemanticColors && + other.color == color && + other.onColor == onColor && + other.container == container && + other.onContainer == onContainer; + + @override + int get hashCode => Object.hash(color, onColor, container, onContainer); +} diff --git a/app/lib/ui/toast/toast_host.dart b/app/lib/ui/toast/toast_host.dart new file mode 100644 index 00000000..4a2082be --- /dev/null +++ b/app/lib/ui/toast/toast_host.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/ui/theme/app_layout.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; +import 'package:fmp/ui/toast/toaster.dart'; + +/// 視窗底部被外殼佔住的高度(dp,從視窗底邊算起,含系統的安全區),例如手機 +/// 的迷你播放列加底部導覽列、桌面的播放列(ADR 0023 §決定 2)。 +/// +/// 外殼在自己的版面改變時寫入;沒有外殼(全螢幕頁、M1 的身分頁)時是 0,提示 +/// 貼著底部安全區。 +final toastBottomInsetProvider = NotifierProvider( + ToastBottomInset.new, +); + +final class ToastBottomInset extends Notifier { + @override + double build() => 0; + + /// 外殼發佈目前佔住的高度。 + void set(double height) => state = height; +} + +/// 顯示 [Toaster] 送來的提示(ADR 0023 §決定 2、3)。 +/// +/// 放在 `MaterialApp.builder`,以自己的 `ScaffoldMessenger` 與透明的 `Scaffold` +/// 包住 Navigator:提示畫在所有路由之上,全螢幕頁、對話框、底部面板開著時都 +/// 看得到。頁面自己的 `Scaffold` 是它的子孫,所以提示只出現在這一層。 +/// +/// - 一次一則:新的立刻取代目前的(不排隊)。 +/// - 時長依 [ToastKind.duration];系統開了無障礙導覽(TalkBack 等)時停留到 +/// 使用者關閉,並顯示關閉鈕。 +/// - App 在背景(hidden/paused)時不顯示;錯誤已由 [Toaster] 寫進錯誤歷史。 +class ToastHost extends ConsumerStatefulWidget { + const ToastHost({super.key, required this.child}); + + /// `MaterialApp.builder` 給的 Navigator。 + final Widget child; + + @override + ConsumerState createState() => _ToastHostState(); +} + +class _ToastHostState extends ConsumerState { + final _messengerKey = GlobalKey(); + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + ref.listenManual(toasterProvider, (_, toaster) { + unawaited(_subscription?.cancel()); + _subscription = toaster.toasts.listen(_show); + }, fireImmediately: true); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); + } + + bool get _inForeground => switch (SchedulerBinding.instance.lifecycleState) { + AppLifecycleState.hidden || + AppLifecycleState.paused || + AppLifecycleState.detached => false, + // 桌面視窗沒有焦點時是 inactive,仍在畫面上。 + AppLifecycleState.resumed || AppLifecycleState.inactive || null => true, + }; + + void _show(Toast toast) { + final messenger = _messengerKey.currentState; + if (messenger == null || !mounted || !_inForeground) return; + messenger + ..removeCurrentSnackBar() + ..showSnackBar(_snackBarFor(toast)); + } + + SnackBar _snackBarFor(Toast toast) { + final theme = Theme.of(context); + final tokens = AppTokens.of(context); + final media = MediaQuery.of(context); + final (background, foreground, icon) = switch (toast.kind) { + // 資訊用 M3 snackbar 的預設配色。 + ToastKind.info => ( + theme.colorScheme.inverseSurface, + theme.colorScheme.onInverseSurface, + Icons.info_outline, + ), + ToastKind.success => ( + tokens.success.container, + tokens.success.onContainer, + Icons.check_circle_outline, + ), + ToastKind.warning => ( + tokens.warning.container, + tokens.warning.onContainer, + Icons.warning_amber_outlined, + ), + ToastKind.error => ( + theme.colorScheme.errorContainer, + theme.colorScheme.onErrorContainer, + Icons.error_outline, + ), + }; + final accessible = media.accessibleNavigation; + return SnackBar( + content: Row( + children: [ + Icon(icon, color: foreground), + SizedBox(width: tokens.spacing.x3), + Expanded( + child: Text( + toast.message, + style: theme.textTheme.bodyMedium?.copyWith(color: foreground), + ), + ), + ], + ), + backgroundColor: background, + behavior: SnackBarBehavior.floating, + margin: _margin(media, tokens.spacing), + duration: toast.kind.duration, + // SnackBar 帶動作時預設不消失;ADR 0023 要照時長消失,只有無障礙導覽 + // 開著時才停留。 + persist: accessible, + showCloseIcon: accessible, + closeIconColor: foreground, + action: switch (toast.action) { + final action? => SnackBarAction( + label: action.label, + textColor: foreground, + onPressed: action.onPressed, + ), + null => null, + }, + ); + } + + /// 桌面置中、最寬 [AppLayout.toastMaxWidth];底部避開外殼發佈的高度與鍵盤。 + /// + /// `Scaffold` 已經把 floating 的 SnackBar 放在底部安全區之上,所以只補超出 + /// 安全區的部分。 + EdgeInsets _margin(MediaQueryData media, AppSpacing spacing) { + final occupied = math.max( + ref.read(toastBottomInsetProvider), + media.viewInsets.bottom, + ); + final side = math.max( + spacing.x4, + (media.size.width - AppLayout.toastMaxWidth) / 2, + ); + return EdgeInsets.fromLTRB( + side, + 0, + side, + spacing.x4 + math.max(0.0, occupied - media.viewPadding.bottom), + ); + } + + // 最外層的 Overlay 給提示自己的 tooltip(無障礙導覽時的關閉鈕)用:宿主在 + // Navigator 之上,Navigator 的 Overlay 在它下面,找不到。 + @override + Widget build(BuildContext context) => Overlay.wrap( + child: ScaffoldMessenger( + key: _messengerKey, + child: Scaffold( + // 宿主本身不畫東西,頁面由 Navigator 裡的路由自己畫。 + // ignore: fmp_lints/fmp_design_tokens — 透明是「不畫」,不是設計值。 + backgroundColor: Colors.transparent, + // 鍵盤由頁面自己的 Scaffold 處理;宿主縮了會把整個 Navigator 一起縮。 + resizeToAvoidBottomInset: false, + body: widget.child, + ), + ), + ); +} diff --git a/app/lib/ui/toast/toaster.dart b/app/lib/ui/toast/toaster.dart new file mode 100644 index 00000000..9e74ccf2 --- /dev/null +++ b/app/lib/ui/toast/toaster.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; +import 'package:fmp/ui/errors/error_message.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; + +/// 提示的語意(ADR 0023 §決定 2):決定顏色、圖示與時長。 +enum ToastKind { + success, + info, + warning, + error; + + /// 自動消失前停留多久:成功與資訊 4 秒,警告與錯誤 6 秒。 + Duration get duration => switch (this) { + success || info => const Duration(seconds: 4), + warning || error => const Duration(seconds: 6), + }; +} + +/// 提示上的動作;每則最多一個(ADR 0023 §決定 1)。[label] 是翻譯過的字串。 +@immutable +final class ToastAction { + const ToastAction({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; +} + +/// 一則要顯示的提示;[message] 已經是翻譯過的文字。 +@immutable +final class Toast { + const Toast({required this.kind, required this.message, this.action}); + + final ToastKind kind; + final String message; + final ToastAction? action; +} + +/// App 唯一的提示入口(ADR 0023 §決定 1),以 [toasterProvider] 取得,不需要 +/// `BuildContext`。 +/// +/// 只給使用者動作的回饋用;背景工作不呼叫它,只更新對應畫面的狀態。它不畫 +/// 任何東西:去重之後把 [Toast] 送進 [toasts],由 `ToastHost` 顯示。 +final class Toaster { + Toaster({ + required this._log, + required this._translations, + required this._sourceName, + }); + + /// 同一類提示在這段時間內只顯示一次(ADR 0013 §決定 5 的「短時間」)。 + static const dedupeWindow = Duration(seconds: 5); + + final Log _log; + final Translations Function() _translations; + final String? Function(String pluginId) _sourceName; + + final _toasts = StreamController.broadcast(sync: true); + final _lastShown = {}; + + /// 通過去重的提示。沒有 `ToastHost` 在聽時就丟掉。 + Stream get toasts => _toasts.stream; + + /// [message] 是翻譯過的字串(`translationsProvider`)。 + void success(String message, {ToastAction? action}) => + _message(ToastKind.success, message, action); + + void info(String message, {ToastAction? action}) => + _message(ToastKind.info, message, action); + + void warning(String message, {ToastAction? action}) => + _message(ToastKind.warning, message, action); + + /// 使用者動作失敗。先經 `log.report` 寫進錯誤歷史(不論之後有沒有被去重 + /// 掉),再依 ADR 0013 的類別表顯示翻譯過的訊息;只收 [AppError],所以 + /// 例外或伺服器的原文上不了畫面。 + /// + /// [operation] 是失敗的動作(英文,例如 `'Search failed'`),[tag] 是模組 + /// 或音源 id,比照 `log.report`。去重以「錯誤類別+音源」為鍵。 + void error( + AppError error, { + required String operation, + required String tag, + ToastAction? action, + }) { + _log.report(operation, error, tag: tag); + final pluginId = error.pluginId; + _show( + Toast( + kind: ToastKind.error, + message: errorMessage( + _translations(), + error, + sourceName: pluginId == null + ? null + : _sourceName(pluginId) ?? pluginId, + ), + action: action, + ), + key: (error.typeName, pluginId), + ); + } + + void _message(ToastKind kind, String message, ToastAction? action) => _show( + Toast(kind: kind, message: message, action: action), + key: (kind, message), + ); + + void _show(Toast toast, {required Object key}) { + final now = clock.now(); + _lastShown.removeWhere((_, shown) => now.difference(shown) >= dedupeWindow); + if (_lastShown.containsKey(key)) return; + _lastShown[key] = now; + _toasts.add(toast); + } + + void dispose() => _toasts.close(); +} + +/// App 的 [Toaster]。錯誤訊息用目前的介面語言,音源名稱取插件 manifest 的 +/// `name`,插件不在清單裡時用 `pluginId`(manifest 驗過格式)。 +final toasterProvider = Provider((ref) { + final toaster = Toaster( + log: ref.watch(logProvider), + translations: () => ref.read(translationsProvider), + sourceName: (pluginId) => + ref.read(pluginRegistryProvider).value?[pluginId]?.manifest.name, + ); + ref.onDispose(toaster.dispose); + return toaster; +}); diff --git a/app/pubspec.lock b/app/pubspec.lock index 66a6ae71..c835742f 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "0.5.2" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e @@ -445,7 +445,7 @@ packages: source: sdk version: "0.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" @@ -804,6 +804,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.6" + serial_csv: + dependency: transitive + description: + name: serial_csv + sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd" + url: "https://pub.dev" + source: hosted + version: "0.5.2" shelf: dependency: transitive description: @@ -841,6 +849,22 @@ packages: description: flutter source: sdk version: "0.0.0" + slang: + dependency: "direct main" + description: + name: slang + sha256: "6091c2b4cd9f663080ede24fde6bcb9ee43f9fad1f9eaa66e2801eee47684e24" + url: "https://pub.dev" + source: hosted + version: "4.19.2" + slang_build_runner: + dependency: "direct dev" + description: + name: slang_build_runner + sha256: f2b6c5fc1f5bc41de80b05e95e7d5d03b5c3041082877ce931fc7ba61cd08afe + url: "https://pub.dev" + source: hosted + version: "4.19.0" source_gen: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9bd01eb6..79c5cbed 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,6 +14,9 @@ workspace: - packages/fmp_lints dependencies: + # Toast 去重的時間(ADR 0023 §決定 2):lib/ 以 clock.now() 讀時間,測試的 + # fake_async 才改得到它。 + clock: ^1.1.3 # 插件宿主 API 的 crypto.md5/sha256(ADR 0014 §決定 5)。 crypto: ^3.0.7 # 網路層(ADR 0012 §決定 1):每插件一個 dio 與記憶體 cookie jar。三個都只准 @@ -36,6 +39,10 @@ dependencies: # 播放後端(ADR 0018 §決定 3):just_audio(ExoPlayer)給 Android,media_kit # (libmpv)給 Windows;兩者只准在 lib/playback/backends/ import # (fmp_layer_imports)。 + # slang 的產生檔(lib/i18n/strings.g.dart)一律 import 它,不宣告就是靠 + # material_ui 的傳遞依賴;數字縮寫(ADR 0024 §決定 6)也用它。版本跟 + # material_ui 的限制走。 + intl: ^0.20.2 just_audio: ^0.10.6 # Flutter 3.47 起 Material 以獨立套件發佈,app/ 直接用它,不 import # package:flutter/material.dart(app/AGENTS.md § Material)。 @@ -47,6 +54,9 @@ dependencies: media_kit_libs_windows_audio: ^1.0.9 path: ^1.9.1 path_provider: ^2.1.6 + # 介面字串(ADR 0024 §決定 7)。不用 slang_flutter:翻譯以 Riverpod 注入 + # (lib/ui/i18n/),不用 slang 的 LocaleSettings/TranslationProvider。 + slang: ^4.19.2 sqlite3: ^3.6.0 # log 門面的歷史與分派(ADR 0011 §決定 1);只准在 lib/core/logging/ # import(fmp_log_facade)。 @@ -66,6 +76,9 @@ dev_dependencies: # 插件執行環境的實機量測(integration_test/plugin_runtime_benchmark_test.dart)。 integration_test: sdk: flutter + # 讓 `dart run build_runner build` 一併產生 lib/i18n/ 的翻譯程式碼(設定在 + # build.yaml)。它釘 slang 的 minor 版本,兩者一起升。 + slang_build_runner: ^4.19.0 yaml: ^3.1.4 flutter: diff --git a/app/test/app/database_error_app_test.dart b/app/test/app/database_error_app_test.dart index 8ddeb7a9..0a47d751 100644 --- a/app/test/app/database_error_app_test.dart +++ b/app/test/app/database_error_app_test.dart @@ -1,22 +1,40 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/app_scope.dart'; import 'package:fmp/app/database_error_app.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; void main() { - testWidgets('says the database cannot be opened and shows the error', ( - tester, - ) async { - await tester.pumpWidget( - DatabaseErrorApp( - flavor: AppFlavor.dev, - error: const FormatException('file is not a database'), - ), - ); + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + + for (final (system, title) in [ + (const Locale('zh', 'TW'), '無法開啟資料庫'), + (const Locale('zh', 'CN'), '无法打开数据库'), + (const Locale('en', 'US'), "Can't open the database"), + ]) { + testWidgets('says the database cannot be opened in $system', ( + tester, + ) async { + dispatcher.localesTestValue = [system]; + addTearDown(dispatcher.clearLocalesTestValue); + + await tester.pumpWidget( + appProviderScope( + child: DatabaseErrorApp( + flavor: AppFlavor.dev, + error: const FormatException('file is not a database'), + fontFallback: FontFallback.none, + ), + ), + ); - expect(find.text('無法開啟資料庫'), findsOneWidget); - expect( - find.text('FormatException: file is not a database'), - findsOneWidget, - ); - }); + expect(find.text(title), findsOneWidget); + expect( + find.text('FormatException: file is not a database'), + findsOneWidget, + ); + }); + } } diff --git a/app/test/app/fmp_app_test.dart b/app/test/app/fmp_app_test.dart index e79bd32c..5ca59b08 100644 --- a/app/test/app/fmp_app_test.dart +++ b/app/test/app/fmp_app_test.dart @@ -8,14 +8,52 @@ import 'package:fmp/core/core_providers.dart'; import 'package:fmp/core/logging/log.dart'; import 'package:fmp/core/logging/log_record.dart'; import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/database/app_database.dart'; import 'package:fmp/data/providers.dart'; import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/domain/appearance.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; +import 'package:material_ui/material_ui.dart'; import '../plugins/plugin_harness.dart'; import '../support/memory_database.dart'; void main() { + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + + /// 以身分頁啟動 App;回傳它的 log,讀記憶體歷史用。 + Future pumpApp( + WidgetTester tester, { + AppDatabase? database, + PlatformCapabilities capabilities = PlatformCapabilities.none, + }) async { + final redactor = Redactor(); + final log = Log(redactor: redactor, minimumLevel: LogLevel.debug); + await tester.pumpWidget( + appProviderScope( + overrides: [ + dataDirectoryProvider.overrideWithValue(Directory('/data/fmp-dev')), + appDatabaseProvider.overrideWithValue(database ?? memoryDatabase()), + redactorProvider.overrideWithValue(redactor), + logProvider.overrideWithValue(log), + platformCapabilitiesProvider.overrideWithValue(capabilities), + ], + child: const FmpApp(flavor: AppFlavor.dev), + ), + ); + return log; + } + + /// 外觀設定從資料庫讀出來之後的畫面(drift 的串流要真的事件迴圈)。 + Future settle(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pumpAndSettle(); + } + testWidgets('shows the app name, flavor, data directory and plugins', ( tester, ) async { @@ -31,21 +69,8 @@ void main() { ), ), ); - final redactor = Redactor(); - await tester.pumpWidget( - appProviderScope( - overrides: [ - dataDirectoryProvider.overrideWithValue(Directory('/data/fmp-dev')), - appDatabaseProvider.overrideWithValue(database), - redactorProvider.overrideWithValue(redactor), - logProvider.overrideWithValue( - Log(redactor: redactor, minimumLevel: LogLevel.debug), - ), - ], - child: const FmpApp(flavor: AppFlavor.dev), - ), - ); + await pumpApp(tester, database: database); // 插件在背景 isolate 載入:spawn 與 port 的訊息要真的事件迴圈,所以在 // runAsync 裡讓它跑,直到清單出現。 final plugin = find.text('fmp-test 1.0.0'); @@ -61,4 +86,179 @@ void main() { expect(find.text('/data/fmp-dev'), findsOneWidget); expect(plugin, findsOneWidget); }); + + group('appearance', () { + setUp(() { + dispatcher.localesTestValue = [const Locale('en', 'US')]; + addTearDown(dispatcher.clearLocalesTestValue); + }); + + Locale appLocale(WidgetTester tester) => + Localizations.localeOf(tester.element(find.text('FMP Dev'))); + + testWidgets('an unset language follows the system', (tester) async { + await pumpApp(tester); + await settle(tester); + + expect(appLocale(tester), const Locale('en')); + expect(find.text('Theme'), findsOneWidget); + }); + + testWidgets('switching the language re-renders the page and back', ( + tester, + ) async { + await pumpApp(tester); + await settle(tester); + + await tester.tap(find.text('繁體中文')); + await settle(tester); + expect( + appLocale(tester), + const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + countryCode: 'TW', + ), + ); + expect(find.text('主題'), findsOneWidget); + // Material 的內建字串也是繁中。 + expect( + MaterialLocalizations.of(tester.element(find.text('FMP Dev'))) + .okButtonLabel, + '確定', + ); + + await tester.tap(find.text('简体中文')); + await settle(tester); + expect(find.text('主题'), findsOneWidget); + + // 主題也有「跟随系统」;點語言的那一個。 + await tester.tap( + find.widgetWithText(RadioListTile, '跟随系统'), + ); + await settle(tester); + expect(appLocale(tester), const Locale('en')); + expect(find.text('FMP Dev'), findsOneWidget); + }); + + testWidgets('CJK text takes the glyphs of the UI language', (tester) async { + // 文字實際用的 locale:樣式的優先,再來是 Text.locale,最後是 App 的。 + // Android 不指名字型,漢字的繁簡字形只看它(研究檔 §6)。 + Locale textLocale(Finder text) { + final rich = tester.widget( + find.descendant(of: text, matching: find.byType(RichText)), + ); + return rich.text.style?.locale ?? + rich.locale ?? + Localizations.localeOf(tester.element(text)); + } + + const hant = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + countryCode: 'TW', + ); + const hans = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + countryCode: 'CN', + ); + final endonyms = { + for (final (name, locale) in [('繁體中文', hant), ('简体中文', hans)]) + find.text(name): locale, + }; + await pumpApp(tester); + await settle(tester); + + // 英文介面:漢字用繁中字形(ADR 0024 §決定 2),英文的 Material 字串不變。 + expect(appLocale(tester), const Locale('en')); + expect(textLocale(find.text('FMP Dev')), hant); + expect(textLocale(find.text('Dark')), hant); + for (final MapEntry(key: text, value: locale) in endonyms.entries) { + expect(textLocale(text), locale, reason: 'endonyms keep their own'); + } + + await tester.tap(find.text('简体中文')); + await settle(tester); + expect(textLocale(find.text('FMP Dev')), hans); + expect(textLocale(find.text('深色')), hans); + for (final MapEntry(key: text, value: locale) in endonyms.entries) { + expect(textLocale(text), locale, reason: 'endonyms keep their own'); + } + + await tester.tap(find.text('繁體中文')); + await settle(tester); + expect(textLocale(find.text('FMP Dev')), hant); + expect(textLocale(find.text('深色')), hant); + }); + + testWidgets('switching the theme changes the brightness', (tester) async { + await pumpApp(tester); + await settle(tester); + Brightness brightness() => + Theme.of(tester.element(find.text('FMP Dev'))).brightness; + + await tester.tap(find.text('Dark')); + await settle(tester); + expect(brightness(), Brightness.dark); + + await tester.tap(find.text('Light')); + await settle(tester); + expect(brightness(), Brightness.light); + expect(find.text('FMP Dev'), findsOneWidget); + }); + + testWidgets('the theme uses the platform fonts for the UI language', ( + tester, + ) async { + const capabilities = PlatformCapabilities( + dataDirectory: true, + singleInstance: false, + fontFallback: FontFallback( + traditionalChinese: ['TC Font'], + simplifiedChinese: ['SC Font'], + ), + playback: null, + ); + final log = await pumpApp(tester, capabilities: capabilities); + await settle(tester); + List? fallback() => + Theme.of(tester.element(find.text('FMP Dev'))) + .textTheme + .bodyMedium + ?.fontFamilyFallback; + + // 英文介面:繁中在前(ADR 0024 §決定 2)。 + expect(fallback(), ['TC Font', 'SC Font']); + await tester.tap(find.text('简体中文')); + await settle(tester); + expect(fallback(), ['SC Font']); + await tester.tap(find.text('繁體中文')); + await settle(tester); + expect(fallback(), ['TC Font']); + await tester.tap(find.text('English')); + await settle(tester); + expect(fallback(), ['TC Font', 'SC Font']); + await tester.tap(find.text('简体中文')); + await settle(tester); + expect(fallback(), ['SC Font']); + + // 啟動與每次換語言各記一筆,實機對照用;記的清單是那個語言的,不是 + // 上一個語言的(listener 裡 read 相依的 provider 會讀到舊值)。 + final applied = [ + for (final record in log.history) + if (record.message == 'UI locale applied') record.fields, + ]; + expect(applied, [ + for (final (locale, fonts) in [ + ('en', ['TC Font', 'SC Font']), + ('zh-Hans-CN', ['SC Font']), + ('zh-Hant-TW', ['TC Font']), + ('en', ['TC Font', 'SC Font']), + ('zh-Hans-CN', ['SC Font']), + ]) + {'locale': locale, 'fontFallback': fonts}, + ]); + }); + }); } diff --git a/app/test/app/unsupported_platform_app_test.dart b/app/test/app/unsupported_platform_app_test.dart index cd79c812..37f1c83c 100644 --- a/app/test/app/unsupported_platform_app_test.dart +++ b/app/test/app/unsupported_platform_app_test.dart @@ -1,15 +1,30 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/app_scope.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); - }); + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + + for (final (system, text) in [ + (const Locale('zh', 'TW'), '此平台尚未支援'), + (const Locale('en', 'US'), "This platform isn't supported yet"), + ]) { + testWidgets('tells the user the platform is not supported in $system', ( + tester, + ) async { + dispatcher.localesTestValue = [system]; + addTearDown(dispatcher.clearLocalesTestValue); + + await tester.pumpWidget( + appProviderScope( + child: const UnsupportedPlatformApp(flavor: AppFlavor.dev), + ), + ); + + expect(find.text(text), findsOneWidget); + }); + } } diff --git a/app/test/core/errors/app_error_surface_test.dart b/app/test/core/errors/app_error_surface_test.dart index f69f57da..ede8ebd3 100644 --- a/app/test/core/errors/app_error_surface_test.dart +++ b/app/test/core/errors/app_error_surface_test.dart @@ -22,7 +22,7 @@ const _reviewedSurface = { 'AppError.retryable': 'bool', 'AppError.retryAfter': 'Duration?', 'AppError.messageKey': 'ErrorMessageKey', - 'AppError.messageArgs': 'Map', + 'AppError.messageArgs': 'Map', 'AppError.expected': 'bool', 'AppError.networkRecordId': 'int?', 'AppError.typeName': 'String', @@ -124,6 +124,12 @@ void main() { 'final class UnexpectedError extends AppError {', "\n static String get fallback => '';", ), + // messageArgs 收窄前的型別:值裝得下插件或伺服器的原文。 + 'text in a type argument': ( + _library, + 'final UnavailableReason reason;', + '\n final Map extraArgs = const {};', + ), }; for (final MapEntry(key: name, value: (path, anchor, insert)) in violations.entries) { @@ -242,14 +248,14 @@ String _key( return '${owner == null ? '' : '$owner.'}${name.lexeme}$suffix'; } -/// 可能被當成文字顯示的成員:型別是字串、裝得下任意值(`Object`、 -/// `dynamic`),或沒寫型別;扣掉 [_allowedTextMembers]。 +/// 可能被當成文字顯示的成員:型別裡有字串或裝得下任意值的型別(`Object`、 +/// `dynamic`),型別參數也算(`Map`、`List`),或沒寫 +/// 型別;扣掉 [_allowedTextMembers]。 Set textMembers(Map members) => { for (final MapEntry(key: name, value: type) in members.entries) if (!_allowedTextMembers.contains(name) && - switch (type?.replaceAll('?', '')) { - null || 'String' || 'Object' || 'dynamic' => true, - _ => false, - }) + (type == null || _textTypes.hasMatch(type))) name, }; + +final _textTypes = RegExp(r'\b(?:String|Object|dynamic)\b'); diff --git a/app/test/core/errors/app_error_test.dart b/app/test/core/errors/app_error_test.dart index 00cb0087..0b375380 100644 --- a/app/test/core/errors/app_error_test.dart +++ b/app/test/core/errors/app_error_test.dart @@ -99,12 +99,12 @@ void main() { pluginId: 'bilibili', retryable: true, messageKey: ErrorMessageKey.unavailable, - messageArgs: {'count': 3}, + messageArgs: {ErrorMessageArg.count: 3}, ); expect(error.retryable, isTrue); expect(error.messageKey, ErrorMessageKey.unavailable); - expect(error.messageArgs, {'count': 3}); + expect(error.messageArgs, {ErrorMessageArg.count: 3}); expect(RateLimited(retryable: false).retryable, isFalse); }); diff --git a/app/test/i18n/translations_test.dart b/app/test/i18n/translations_test.dart new file mode 100644 index 00000000..eb89eb15 --- /dev/null +++ b/app/test/i18n/translations_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:path/path.dart' as p; + +// ADR 0024 §如何確認:三語言的 key 集合相同,缺一條就紅。slang 設了 +// `fallback_strategy: base_locale`,缺的字串會退回繁中照樣編譯,所以這裡是 +// 唯一擋住漏翻的地方。檔尾的變異案例證明比對抓得到違規,也不被無關的改動 +// 影響(.trellis/spec/app/testing/index.md)。 + +const _directory = 'lib/i18n'; +const _suffix = '.i18n.json'; + +/// 翻譯檔的內容:語言 → 攤平的 key(`errors.network`)→ 字串。 +typedef Catalog = Map>; + +Catalog readCatalog() => { + for (final file in Directory(_directory).listSync().whereType()) + if (file.path.endsWith(_suffix)) + p.basename(file.path).replaceAll(_suffix, ''): flatten( + jsonDecode(file.readAsStringSync()) as Map, + ), +}; + +/// 巢狀的 JSON 攤平成 `a.b.c` → 字串。 +Map flatten(Map json, [String prefix = '']) => + { + for (final MapEntry(:key, :value) in json.entries) + ...switch (value) { + final Map nested => flatten(nested, '$prefix$key.'), + final String text => {'$prefix$key': text}, + _ => throw FormatException('Unexpected value at $prefix$key: $value'), + }, + }; + +/// `{name}` 參數(slang 的 `string_interpolation: braces`);`\{` 是跳脫。 +Set placeholders(String text) => { + for (final match in RegExp(r'(? parityProblems(Catalog catalog) { + final base = catalog['zh-TW']!; + return [ + for (final MapEntry(key: locale, value: strings) in catalog.entries) + if (locale != 'zh-TW') ...[ + for (final key in base.keys) + if (!strings.containsKey(key)) '$locale is missing $key', + for (final key in strings.keys) + if (!base.containsKey(key)) '$locale has $key, zh-TW does not', + for (final key in strings.keys) + if (base[key] case final baseText? + when !_sameSet( + placeholders(baseText), + placeholders(strings[key]!), + )) + '$locale has other parameters in $key', + ], + ]; +} + +bool _sameSet(Set a, Set b) => + a.length == b.length && a.containsAll(b); + +void main() { + final catalog = readCatalog(); + + test('there is one file per supported language', () { + expect(catalog.keys.toSet(), {'zh-TW', 'zh-CN', 'en'}); + expect({ + for (final locale in AppLocale.values) locale.languageTag, + }, catalog.keys.toSet()); + expect(AppLocaleUtils.instance.baseLocale, AppLocale.zhTw); + }); + + test('every language has the same keys and parameters', () { + expect(parityProblems(catalog), isEmpty); + }); + + test('no string is empty', () { + for (final MapEntry(key: locale, value: strings) in catalog.entries) { + for (final MapEntry(:key, :value) in strings.entries) { + expect(value.trim(), isNotEmpty, reason: '$locale $key'); + } + } + }); + + group('error messages', () { + for (final MapEntry(key: locale, value: strings) in catalog.entries) { + test('every ErrorMessageKey has a string in $locale', () { + for (final key in ErrorMessageKey.values) { + expect(strings, contains('errors.${key.name}')); + } + }); + + test('every UnavailableReason has a string in $locale', () { + for (final reason in UnavailableReason.values) { + expect(strings, contains('errors.unavailableReasons.${reason.name}')); + } + }); + } + }); + + group('mutations', () { + Catalog mutate( + String locale, + Map Function(Map) change, + ) => { + ...catalog, + locale: change({...catalog[locale]!}), + }; + + test('catches a missing key', () { + final mutated = mutate( + 'en', + (strings) => strings..remove('errors.network'), + ); + + expect(parityProblems(mutated), ['en is missing errors.network']); + }); + + test('catches a key only one language has', () { + final mutated = mutate( + 'zh-CN', + (strings) => strings..['errors.extra'] = '多的', + ); + + expect(parityProblems(mutated), [ + 'zh-CN has errors.extra, zh-TW does not', + ]); + }); + + test('catches a renamed parameter', () { + final mutated = mutate( + 'en', + (strings) => + strings..['errors.rateLimited'] = 'Too many requests to {plugin}.', + ); + + expect(parityProblems(mutated), [ + 'en has other parameters in errors.rateLimited', + ]); + }); + + test('ignores reworded text, reordered keys and JSON formatting', () { + final reworded = mutate( + 'en', + (strings) => Map.fromEntries(strings.entries.toList().reversed) + ..['errors.network'] = 'Offline. Try again.' + ..['errors.rateLimited'] = '{source} is busy. Escaped \\{brace}.', + ); + final reformatted = flatten( + jsonDecode( + const JsonEncoder.withIndent(' ').convert( + jsonDecode(File('$_directory/en$_suffix').readAsStringSync()), + ), + ) as Map, + ); + + expect(parityProblems(reworded), isEmpty); + expect(parityProblems({...catalog, 'en': reformatted}), isEmpty); + }); + }); +} diff --git a/app/test/ui/errors/error_message_test.dart b/app/test/ui/errors/error_message_test.dart new file mode 100644 index 00000000..e202c218 --- /dev/null +++ b/app/test/ui/errors/error_message_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/plugins/runtime/script_errors.dart'; +import 'package:fmp/ui/errors/error_message.dart'; + +/// 每個 [ErrorMessageKey] 一個錯誤。 +final _byKey = { + ErrorMessageKey.network: NetworkError(), + ErrorMessageKey.rateLimited: RateLimited(pluginId: 'bilibili'), + ErrorMessageKey.authRequired: AuthRequired(pluginId: 'bilibili'), + ErrorMessageKey.credentialInvalid: CredentialInvalid(pluginId: 'bilibili'), + ErrorMessageKey.verificationRequired: VerificationRequired( + pluginId: 'bilibili', + ), + ErrorMessageKey.unavailable: Unavailable(reason: UnavailableReason.copyright), + ErrorMessageKey.notFound: NotFound(), + ErrorMessageKey.parseError: ParseError(pluginId: 'bilibili'), + ErrorMessageKey.unexpected: UnexpectedError(), +}; + +void main() { + test('the samples cover every key', () { + expect(_byKey.keys.toSet(), ErrorMessageKey.values.toSet()); + for (final MapEntry(:key, :value) in _byKey.entries) { + expect(value.messageKey, key); + } + }); + + for (final locale in AppLocale.values) { + group(locale.languageTag, () { + final t = locale.buildSync(); + + test('every key has its own message', () { + final messages = { + for (final error in _byKey.values) + errorMessage(t, error, sourceName: 'Bilibili'), + }; + expect(messages, hasLength(_byKey.length)); + expect(messages, everyElement(isNotEmpty)); + }); + + test('the source name goes where the message names the source', () { + for (final key in [ + ErrorMessageKey.rateLimited, + ErrorMessageKey.authRequired, + ErrorMessageKey.credentialInvalid, + ErrorMessageKey.verificationRequired, + ErrorMessageKey.parseError, + ]) { + final error = _byKey[key]!; + expect( + errorMessage(t, error, sourceName: 'Bilibili'), + contains('Bilibili'), + ); + expect( + errorMessage(t, error), + contains(t.errors.unknownSource), + reason: 'no source name falls back to the generic word', + ); + } + }); + + test('an unavailable item says why', () { + for (final reason in UnavailableReason.values) { + expect( + errorMessage(t, Unavailable(reason: reason)), + contains(unavailableReasonText(t, reason)), + ); + } + // 音源把別的類別覆寫成 unavailable 時沒有原因,用不帶原因的句子。 + expect( + errorMessage(t, NotFound(messageKey: ErrorMessageKey.unavailable)), + t.errors.unavailable, + ); + }); + + test("a plugin's own message never reaches the text", () { + for (final name in [ + 'RateLimited', + 'ParseError', + 'UnexpectedError', + 'no such class', + ]) { + final error = structuredScriptError( + pluginId: 'fmp-test', + fmpError: name, + message: 'FAKE_SERVER_TEXT_123', + ); + expect( + errorMessage(t, error, sourceName: 'Test'), + isNot(contains('FAKE_SERVER_TEXT_123')), + ); + } + }); + }); + } +} diff --git a/app/test/ui/guidelines_test.dart b/app/test/ui/guidelines_test.dart new file mode 100644 index 00000000..05b52763 --- /dev/null +++ b/app/test/ui/guidelines_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/data/providers.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/ui/settings/appearance_controls.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:fmp/ui/toast/toaster.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../support/memory_database.dart'; + +// ADR 0024 §如何確認:淺色與深色主題下通過點擊區與對比度 guideline。M1 還沒有 +// 正式頁面(12b),這裡以示範畫面驗證主題本身:M3 元件、token 的語意色、四種 +// 提示,加上身分頁用的外觀設定控制項。 + +/// 示範畫面:文字角色、常見按鈕、外觀設定控制項。 +class _Demo extends StatelessWidget { + const _Demo(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = AppTokens.of(context); + return Scaffold( + appBar: AppBar(title: const Text('示範')), + body: SingleChildScrollView( + padding: EdgeInsets.all(tokens.spacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('標題', style: theme.textTheme.titleLarge), + Text('內文', style: theme.textTheme.bodyMedium), + Text('說明', style: theme.textTheme.bodySmall), + Wrap( + spacing: tokens.spacing.x2, + children: [ + FilledButton(onPressed: () {}, child: const Text('確定')), + OutlinedButton(onPressed: () {}, child: const Text('取消')), + TextButton(onPressed: () {}, child: const Text('更多')), + IconButton( + tooltip: '播放', + onPressed: () {}, + icon: const Icon(Icons.play_arrow), + ), + ], + ), + const AppearanceControls(), + ], + ), + ), + ); + } +} + +void main() { + for (final brightness in Brightness.values) { + group('$brightness', () { + late Toaster toaster; + + Future pumpDemo(WidgetTester tester) async { + toaster = Toaster( + log: Log(redactor: Redactor(), minimumLevel: LogLevel.debug), + translations: AppLocale.zhTw.buildSync, + sourceName: (_) => null, + ); + addTearDown(toaster.dispose); + final database = memoryDatabase(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appDatabaseProvider.overrideWithValue(database), + toasterProvider.overrideWithValue(toaster), + ], + child: MaterialApp( + theme: buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: _hant, + ), + darkTheme: buildAppTheme( + Brightness.dark, + fontFamilyFallback: const [], + textLocale: _hant, + ), + themeMode: themeModeOf(switch (brightness) { + Brightness.light => ThemeModeSetting.light, + Brightness.dark => ThemeModeSetting.dark, + }), + builder: (context, navigator) => ToastHost(child: navigator!), + home: const _Demo(), + ), + ), + ); + // 外觀設定從記憶體資料庫讀出來(drift 的串流要真的事件迴圈)。 + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pumpAndSettle(); + expect(find.byType(RadioListTile), findsNWidgets(4)); + } + + Future expectGuidelines(WidgetTester tester) async { + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + } + + testWidgets('the demo screen meets the guidelines', (tester) async { + final handle = tester.ensureSemantics(); + await pumpDemo(tester); + + await expectGuidelines(tester); + handle.dispose(); + }); + + for (final kind in ToastKind.values) { + testWidgets('a ${kind.name} toast meets the guidelines', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpDemo(tester); + final action = ToastAction(label: '復原', onPressed: () {}); + switch (kind) { + case ToastKind.success: + toaster.success('已加入歌單', action: action); + case ToastKind.info: + toaster.info('已複製連結', action: action); + case ToastKind.warning: + toaster.warning('儲存空間不足', action: action); + case ToastKind.error: + toaster.error( + RateLimited(pluginId: 'bilibili'), + operation: 'Search failed', + tag: 'test', + action: action, + ); + } + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + + await expectGuidelines(tester); + handle.dispose(); + }); + } + }); + } +} + +const _hant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); diff --git a/app/test/ui/i18n/ui_locale_test.dart b/app/test/ui/i18n/ui_locale_test.dart new file mode 100644 index 00000000..b3aa7d70 --- /dev/null +++ b/app/test/ui/i18n/ui_locale_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/platform/fonts/fonts.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + test('Chinese carries its script so the engine picks the right glyphs', () { + expect( + [for (final locale in LocaleSetting.values) flutterLocaleOf(locale)], + [ + const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + countryCode: 'TW', + ), + const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + countryCode: 'CN', + ), + const Locale('en'), + ], + ); + expect(supportedFlutterLocales, hasLength(LocaleSetting.values.length)); + }); + + test('each setting maps to its slang and font language', () { + expect( + [for (final locale in LocaleSetting.values) appLocaleOf(locale)], + [AppLocale.zhTw, AppLocale.zhCn, AppLocale.en], + ); + expect( + [for (final locale in LocaleSetting.values) fontLanguageOf(locale)], + [FontLanguage.zhTw, FontLanguage.zhCn, FontLanguage.en], + ); + }); + + // Material 的內建字串依書寫系統選:zh_Hant_TW 是台灣用語,zh_Hans 是簡中。 + for (final (locale, ok, back) in [ + (LocaleSetting.zhTw, '確定', '返回'), + (LocaleSetting.zhCn, '确定', '返回'), + (LocaleSetting.en, 'OK', 'Back'), + ]) { + testWidgets('${locale.name} gets matching Material strings', ( + tester, + ) async { + late MaterialLocalizations strings; + await tester.pumpWidget( + MaterialApp( + locale: flutterLocaleOf(locale), + supportedLocales: supportedFlutterLocales, + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Builder( + builder: (context) { + strings = MaterialLocalizations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(strings.okButtonLabel, ok); + expect(strings.backButtonTooltip, back); + }); + } + + test('language names are written in their own language', () { + expect( + [for (final locale in LocaleSetting.values) localeEndonym(locale)], + ['繁體中文', '简体中文', 'English'], + ); + }); +} diff --git a/app/test/ui/layout/window_class_test.dart b/app/test/ui/layout/window_class_test.dart new file mode 100644 index 00000000..01114350 --- /dev/null +++ b/app/test/ui/layout/window_class_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/ui/layout/window_class.dart'; + +void main() { + group('forWidth uses the M3 breakpoints', () { + for (final (width, expected) in [ + (0.0, WindowClass.compact), + (599.9, WindowClass.compact), + (600.0, WindowClass.medium), + (839.9, WindowClass.medium), + (840.0, WindowClass.expanded), + (1199.9, WindowClass.expanded), + (1200.0, WindowClass.large), + (1599.9, WindowClass.large), + (1600.0, WindowClass.extraLarge), + (double.infinity, WindowClass.extraLarge), + ]) { + test('$width is ${expected.name}', () { + expect(WindowClass.forWidth(width), expected); + }); + } + }); + + group('WindowClassScope', () { + /// 在 [width] 寬的區域放一個 scope,子樹是同一個 [probe] 實例:它只在 + /// 依賴的等級改變時重建。 + Future pumpAt(WidgetTester tester, double width, Widget probe) => + tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: WindowClassScope(child: probe), + ), + ), + ), + ); + + Widget probeInto(List seen) => Builder( + builder: (context) { + seen.add(WindowClass.of(context)); + return const SizedBox.shrink(); + }, + ); + + testWidgets('measures the space it is given, not the screen', ( + tester, + ) async { + final seen = []; + await pumpAt(tester, 700, probeInto(seen)); + + expect(seen, [WindowClass.medium]); + }); + + testWidgets('rebuilds dependents only when the class changes', ( + tester, + ) async { + final seen = []; + final probe = probeInto(seen); + // 測試畫面寬 800,寬度都在它之內。 + await pumpAt(tester, 500, probe); + await pumpAt(tester, 550, probe); + await pumpAt(tester, 700, probe); + + expect(seen, [WindowClass.compact, WindowClass.medium]); + }); + }); +} diff --git a/app/test/ui/theme/app_theme_test.dart b/app/test/ui/theme/app_theme_test.dart new file mode 100644 index 00000000..d6e58430 --- /dev/null +++ b/app/test/ui/theme/app_theme_test.dart @@ -0,0 +1,201 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/ui/theme/app_layout.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; +import 'package:material_ui/material_ui.dart'; + +/// WCAG 2 的對比度。 +double _contrast(Color a, Color b) { + final (lighter, darker) = ( + math.max(a.computeLuminance(), b.computeLuminance()), + math.min(a.computeLuminance(), b.computeLuminance()), + ); + return (lighter + 0.05) / (darker + 0.05); +} + +void main() { + const spacing = AppSpacing(); + const radius = AppRadius(); + + test('spacing is the ADR 0024 scale', () { + expect( + [ + spacing.x1, + spacing.x2, + spacing.x3, + spacing.x4, + spacing.x5, + spacing.x6, + spacing.x8, + spacing.x10, + spacing.x12, + ], + [4, 8, 12, 16, 20, 24, 32, 40, 48], + ); + }); + + test('radii are the M3 shape scale', () { + expect( + [ + radius.extraSmall, + radius.small, + radius.medium, + radius.large, + radius.extraLarge, + ], + [4, 8, 12, 16, 28], + ); + }); + + test('the toast is at most 560 wide', () { + expect(AppLayout.toastMaxWidth, 560); + }); + + for (final brightness in Brightness.values) { + group('$brightness theme', () { + final theme = buildAppTheme( + brightness, + fontFamilyFallback: const ['A', 'B'], + textLocale: _hant, + ); + final tokens = theme.extension()!; + + test('is Material 3 from the seed and carries the tokens', () { + expect(theme.useMaterial3, isTrue); + expect(theme.brightness, brightness); + expect( + theme.colorScheme, + ColorScheme.fromSeed(seedColor: appSeedColor, brightness: brightness), + ); + expect(tokens, AppTokens.forScheme(theme.colorScheme)); + }); + + test('the focus ring is 2dp of primary, 2dp outside', () { + expect(tokens.focusRingColor, theme.colorScheme.primary); + expect(tokens.focusRingWidth, 2); + expect(tokens.focusRingOffset, 2); + }); + + test('semantic colors pair with enough contrast', () { + for (final colors in [tokens.success, tokens.warning]) { + expect( + _contrast(colors.container, colors.onContainer), + greaterThanOrEqualTo(4.5), + ); + expect( + _contrast(colors.color, colors.onColor), + greaterThanOrEqualTo(4.5), + ); + } + }); + + test('every text style gets the font fallback', () { + final styles = [ + theme.textTheme.bodyMedium, + theme.textTheme.titleLarge, + theme.textTheme.labelSmall, + ]; + for (final style in styles) { + expect(style?.fontFamilyFallback, ['A', 'B']); + } + }); + + test('snack bars float', () { + expect(theme.snackBarTheme.behavior, SnackBarBehavior.floating); + }); + }); + } + + // 漢字的繁簡字形跟著樣式的 locale(研究檔 §6):元件自己的樣式也要從主題 + // 繼承到它,英文字不受影響。 + for (final textLocale in [ + _hant, + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + ]) { + testWidgets('widgets inherit the text locale $textLocale', (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + theme: buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: textLocale, + ), + home: Scaffold( + appBar: AppBar(title: const Text('標題')), + body: Column( + children: [ + const Text('內文'), + const TextField(), + ElevatedButton(onPressed: () {}, child: const Text('按鈕')), + SegmentedButton( + segments: const [ButtonSegment(value: 0, label: Text('分段'))], + selected: const {0}, + onSelectionChanged: (_) {}, + ), + const ListTile(title: Text('清單')), + ], + ), + ), + ), + ); + Locale? styleLocale(String text) => tester + .widget( + find.descendant( + of: find.text(text), + matching: find.byType(RichText), + ), + ) + .text + .style + ?.locale; + + for (final text in ['標題', '內文', '按鈕', '分段', '清單']) { + expect(styleLocale(text), textLocale, reason: text); + } + expect( + tester.widget(find.byType(EditableText)).style.locale, + textLocale, + ); + }); + } + + test('no fonts leaves the fallback to the engine', () { + final theme = buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: _hant, + ); + + expect(theme.textTheme.bodyMedium?.fontFamilyFallback, isNull); + }); + + test('tokens interpolate between light and dark', () { + final light = AppTokens.forScheme( + ColorScheme.fromSeed(seedColor: appSeedColor), + ); + final dark = AppTokens.forScheme( + ColorScheme.fromSeed( + seedColor: appSeedColor, + brightness: Brightness.dark, + ), + ); + + expect(light.lerp(dark, 0), light); + expect(light.lerp(dark, 1), dark); + expect(light.lerp(null, 0.5), light); + expect(light.copyWith(), light); + }); + + test('theme modes map one to one', () { + expect( + [for (final mode in ThemeModeSetting.values) themeModeOf(mode)], + [ThemeMode.system, ThemeMode.light, ThemeMode.dark], + ); + }); +} + +const _hant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); diff --git a/app/test/ui/toast/toast_host_test.dart b/app/test/ui/toast/toast_host_test.dart new file mode 100644 index 00000000..ad0a0758 --- /dev/null +++ b/app/test/ui/toast/toast_host_test.dart @@ -0,0 +1,317 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/plugins/runtime/script_errors.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:fmp/ui/toast/toaster.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + final navigatorKey = GlobalKey(); + late ProviderContainer container; + + /// `MaterialApp` 以 `ToastHost` 包住 Navigator,和 App 一樣;回傳 [Toaster]。 + Future pumpHost(WidgetTester tester) async { + final toaster = Toaster( + log: Log(redactor: Redactor(), minimumLevel: LogLevel.debug), + translations: AppLocale.zhTw.buildSync, + sourceName: (_) => null, + ); + addTearDown(toaster.dispose); + container = ProviderContainer.test( + overrides: [toasterProvider.overrideWithValue(toaster)], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + navigatorKey: navigatorKey, + theme: buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: _hant, + ), + builder: (context, navigator) => ToastHost(child: navigator!), + home: const Scaffold(body: Center(child: Text('home'))), + ), + ), + ); + return toaster; + } + + /// 顯示中的提示的那一塊 Material(不含 margin)。 + Finder snackMaterial() => find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + + testWidgets('a new toast replaces the current one at once', (tester) async { + final toaster = await pumpHost(tester); + + toaster.success('第一則'); + await tester.pumpAndSettle(); + toaster.info('第二則'); + await tester.pump(); + + expect(find.text('第一則'), findsNothing); + expect(find.text('第二則'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + }); + + group('durations', () { + for (final (kind, seconds) in [ + (ToastKind.success, 4), + (ToastKind.info, 4), + (ToastKind.warning, 6), + (ToastKind.error, 6), + ]) { + testWidgets('${kind.name} stays $seconds seconds', (tester) async { + final toaster = await pumpHost(tester); + switch (kind) { + case ToastKind.success: + toaster.success('訊息'); + case ToastKind.info: + toaster.info('訊息'); + case ToastKind.warning: + toaster.warning('訊息'); + case ToastKind.error: + toaster.error(NotFound(), operation: 'Open failed', tag: 'test'); + } + await tester.pumpAndSettle(); + + await tester.pump( + Duration(seconds: seconds) - const Duration(milliseconds: 100), + ); + expect(find.byType(SnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + } + + testWidgets('a toast with an action still goes away', (tester) async { + final toaster = await pumpHost(tester); + + toaster.info( + '已刪除', + action: ToastAction(label: '復原', onPressed: () {}), + ); + await tester.pumpAndSettle(); + expect(find.text('復原'), findsOneWidget); + await tester.pump(ToastKind.info.duration); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('with accessible navigation it stays until closed', ( + tester, + ) async { + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown( + tester.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + final toaster = await pumpHost(tester); + + toaster.success('已加入'); + await tester.pumpAndSettle(); + await tester.pump(const Duration(minutes: 1)); + await tester.pumpAndSettle(); + expect(find.text('已加入'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + }); + + group('above every route (ADR 0023 §決定 3)', () { + Future expectOnTop(WidgetTester tester, Toaster toaster) async { + toaster.warning('在最上層'); + await tester.pumpAndSettle(); + // hitTestable:點在文字中央的是提示本身,沒有被路由或遮罩蓋住。 + expect(find.text('在最上層').hitTestable(), findsOneWidget); + } + + testWidgets('a full-screen route', (tester) async { + final toaster = await pumpHost(tester); + navigatorKey.currentState!.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const Scaffold(body: Center(child: Text('全螢幕'))), + ), + ); + await tester.pumpAndSettle(); + + await expectOnTop(tester, toaster); + expect(find.text('全螢幕'), findsOneWidget); + }); + + testWidgets('a dialog', (tester) async { + final toaster = await pumpHost(tester); + showDialog( + context: navigatorKey.currentContext!, + builder: (_) => const AlertDialog(content: Text('對話框')), + ); + await tester.pumpAndSettle(); + + await expectOnTop(tester, toaster); + expect(find.text('對話框'), findsOneWidget); + }); + + testWidgets('a bottom sheet', (tester) async { + final toaster = await pumpHost(tester); + showModalBottomSheet( + context: navigatorKey.currentContext!, + builder: (_) => const SizedBox(height: 600, child: Text('面板')), + ); + await tester.pumpAndSettle(); + + await expectOnTop(tester, toaster); + }); + }); + + group('position', () { + testWidgets('sits above the height the shell publishes', (tester) async { + final toaster = await pumpHost(tester); + const spacing = AppSpacing(); + + toaster.info('底部'); + await tester.pumpAndSettle(); + expect(tester.getRect(snackMaterial()).bottom, 600 - spacing.x4); + + container.read(toastBottomInsetProvider.notifier).set(80); + toaster.info('外殼之上'); + await tester.pumpAndSettle(); + expect(tester.getRect(snackMaterial()).bottom, 600 - 80 - spacing.x4); + }); + + testWidgets('the published height already includes the safe area', ( + tester, + ) async { + tester.view.padding = const FakeViewPadding(bottom: 24); + addTearDown(tester.view.resetPadding); + final toaster = await pumpHost(tester); + container.read(toastBottomInsetProvider.notifier).set(80); + + toaster.info('外殼之上'); + await tester.pumpAndSettle(); + + expect( + tester.getRect(snackMaterial()).bottom, + 600 - 80 - const AppSpacing().x4, + ); + }); + + testWidgets('is at most 560 wide and centred on a wide window', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final toaster = await pumpHost(tester); + + toaster.info('桌面'); + await tester.pumpAndSettle(); + + final rect = tester.getRect(snackMaterial()); + expect(rect.width, 560); + expect(rect.center.dx, 700); + }); + + testWidgets('keeps a margin on a narrow window', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final toaster = await pumpHost(tester); + + toaster.info('手機'); + await tester.pumpAndSettle(); + + expect( + tester.getRect(snackMaterial()).width, + 400 - 2 * const AppSpacing().x4, + ); + }); + }); + + testWidgets('each kind has its color', (tester) async { + final toaster = await pumpHost(tester); + final theme = buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: _hant, + ); + final tokens = theme.extension()!; + Color? background() => + tester.widget(find.byType(SnackBar)).backgroundColor; + + toaster.success('成功'); + await tester.pumpAndSettle(); + expect(background(), tokens.success.container); + toaster.info('資訊'); + await tester.pumpAndSettle(); + expect(background(), theme.colorScheme.inverseSurface); + toaster.warning('警告'); + await tester.pumpAndSettle(); + expect(background(), tokens.warning.container); + toaster.error(NotFound(), operation: 'Open failed', tag: 'test'); + await tester.pumpAndSettle(); + expect(background(), theme.colorScheme.errorContainer); + }); + + testWidgets("a plugin's own message never reaches the screen", ( + tester, + ) async { + final toaster = await pumpHost(tester); + + toaster.error( + structuredScriptError( + pluginId: 'fmp-test', + fmpError: 'RateLimited', + message: 'FAKE_SERVER_TEXT_123', + ), + operation: 'Search failed', + tag: 'search', + ); + await tester.pumpAndSettle(); + + expect(find.text('fmp-test 請求太頻繁,請稍後再試'), findsOneWidget); + expect(find.textContaining('FAKE_SERVER_TEXT_123'), findsNothing); + }); + + testWidgets('nothing is shown while the app is in the background', ( + tester, + ) async { + final toaster = await pumpHost(tester); + final binding = tester.binding; + binding + ..handleAppLifecycleStateChanged(AppLifecycleState.inactive) + ..handleAppLifecycleStateChanged(AppLifecycleState.hidden); + addTearDown( + () => binding + ..handleAppLifecycleStateChanged(AppLifecycleState.inactive) + ..handleAppLifecycleStateChanged(AppLifecycleState.resumed), + ); + + toaster.error(NetworkError(), operation: 'Sync failed', tag: 'test'); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + + // 桌面視窗失去焦點(inactive)仍在畫面上,照常顯示。 + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + toaster.info('失去焦點'); + await tester.pumpAndSettle(); + expect(find.text('失去焦點'), findsOneWidget); + }); +} + +const _hant = Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); diff --git a/app/test/ui/toast/toaster_test.dart b/app/test/ui/toast/toaster_test.dart new file mode 100644 index 00000000..7e352c1e --- /dev/null +++ b/app/test/ui/toast/toaster_test.dart @@ -0,0 +1,197 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/plugins/runtime/script_errors.dart'; +import 'package:fmp/ui/toast/toaster.dart'; + +void main() { + late Log log; + late List shown; + + /// 以繁中與固定的插件名稱建一個 [Toaster],記下它送出的提示。 + Toaster toaster({AppLocale locale = AppLocale.zhTw}) { + log = Log(redactor: Redactor(), minimumLevel: LogLevel.debug); + final toaster = Toaster( + log: log, + translations: locale.buildSync, + sourceName: (pluginId) => pluginId == 'bilibili' ? 'Bilibili' : null, + ); + shown = []; + toaster.toasts.listen(shown.add); + addTearDown(toaster.dispose); + return toaster; + } + + test('durations: 4 seconds for success and info, 6 for the rest', () { + expect( + {for (final kind in ToastKind.values) kind: kind.duration.inSeconds}, + { + ToastKind.success: 4, + ToastKind.info: 4, + ToastKind.warning: 6, + ToastKind.error: 6, + }, + ); + }); + + test('messages pass through with their kind and action', () { + final toasts = toaster(); + final action = ToastAction(label: '復原', onPressed: () {}); + + toasts + ..success('已加入') + ..info('已複製', action: action) + ..warning('空間不足'); + + expect( + [for (final t in shown) (t.kind, t.message, t.action)], + [ + (ToastKind.success, '已加入', null), + (ToastKind.info, '已複製', action), + (ToastKind.warning, '空間不足', null), + ], + ); + }); + + group('errors', () { + test('show the mapped message with the plugin name', () { + toaster().error( + RateLimited(pluginId: 'bilibili'), + operation: 'Search failed', + tag: 'search', + ); + + expect(shown.single.kind, ToastKind.error); + expect(shown.single.message, 'Bilibili 請求太頻繁,請稍後再試'); + }); + + test('an unknown plugin shows its id; no plugin, the generic word', () { + final toasts = toaster(locale: AppLocale.en); + toasts + ..error( + ParseError(pluginId: 'fmp-test'), + operation: 'Load failed', + tag: 'plugins', + ) + ..error(ParseError(), operation: 'Load failed', tag: 'plugins'); + + expect( + [for (final t in shown) t.message], + [ + 'The response format of fmp-test changed. An update may be needed.', + 'The response format of the source changed. An update may be needed.', + ], + ); + }); + + test("never show the plugin's own message", () { + toaster().error( + structuredScriptError( + pluginId: 'bilibili', + fmpError: 'VerificationRequired', + message: 'FAKE_SERVER_TEXT_123', + ), + operation: 'Play failed', + tag: 'playback', + ); + + expect(shown.single.message, isNot(contains('FAKE_SERVER_TEXT_123'))); + }); + + test('go to the error history, also when deduplicated', () { + final toasts = toaster(); + for (var i = 0; i < 2; i++) { + toasts.error( + NetworkError(pluginId: 'bilibili'), + operation: 'Search failed', + tag: 'search', + ); + } + + expect(shown, hasLength(1)); + final reported = [ + for (final record in log.history) + if (record.message == 'Search failed') record, + ]; + expect(reported, hasLength(2)); + expect(reported.first.tag, 'search'); + expect(reported.first.level, LogLevel.warning); + expect(reported.first.fields['type'], 'NetworkError'); + }); + }); + + group('deduplication (5 seconds)', () { + test('the same message shows once within the window', () { + fakeAsync((async) { + final toasts = toaster(); + toasts.success('已加入'); + async.elapse(const Duration(milliseconds: 4999)); + toasts.success('已加入'); + expect(shown, hasLength(1)); + + async.elapse(const Duration(milliseconds: 1)); + toasts.success('已加入'); + expect(shown, hasLength(2)); + }); + }); + + test('a suppressed toast does not extend the window', () { + fakeAsync((async) { + final toasts = toaster(); + toasts.info('已複製'); + async.elapse(const Duration(seconds: 3)); + toasts.info('已複製'); + async.elapse(const Duration(seconds: 2)); + toasts.info('已複製'); + + expect(shown, hasLength(2)); + }); + }); + + test('different messages or kinds are not duplicates', () { + fakeAsync((async) { + toaster() + ..success('已加入') + ..success('已移除') + ..info('已加入'); + + expect(shown, hasLength(3)); + }); + }); + + test('errors are keyed by class and plugin, not by message', () { + fakeAsync((async) { + final toasts = toaster(); + void fail(AppError error) => + toasts.error(error, operation: 'Search failed', tag: 'search'); + + fail(Unavailable(reason: UnavailableReason.region, pluginId: 'a')); + // 同類別、同音源、不同原因:同一類。 + fail(Unavailable(reason: UnavailableReason.age, pluginId: 'a')); + fail(Unavailable(reason: UnavailableReason.region, pluginId: 'b')); + fail(NotFound(pluginId: 'a')); + + expect([for (final t in shown) t.message], hasLength(3)); + + async.elapse(Toaster.dedupeWindow); + fail(Unavailable(reason: UnavailableReason.age, pluginId: 'a')); + expect(shown, hasLength(4)); + }); + }); + }); + + test('without a host the toasts are dropped', () { + final toasts = Toaster( + log: Log(redactor: Redactor(), minimumLevel: LogLevel.debug), + translations: AppLocale.zhTw.buildSync, + sourceName: (_) => null, + ); + addTearDown(toasts.dispose); + + expect(() => toasts.success('已加入'), returnsNormally); + }); +} From 3248fa13214f746e96995beea05542aa9e2271d6 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 18:49:57 +0800 Subject: [PATCH 3/8] docs(app): document ui tokens, translations and toasts --- .trellis/spec/app/errors/index.md | 23 ++++-- .trellis/spec/app/settings/index.md | 14 ++-- .trellis/spec/app/ui/index.md | 123 ++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 .trellis/spec/app/ui/index.md diff --git a/.trellis/spec/app/errors/index.md b/.trellis/spec/app/errors/index.md index 047706d5..a2f060c1 100644 --- a/.trellis/spec/app/errors/index.md +++ b/.trellis/spec/app/errors/index.md @@ -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'); } ``` @@ -53,7 +58,8 @@ throw RateLimited( - 限流帶 `Retry-After` 時用 `parseRetryAfter` 填 `retryAfter`;`now` 用收到回應的時間。 - 預設 `retryable` 不合用時才覆寫(例如某個 5xx 業務碼其實可重試)。覆寫只決定「錯誤 可不可以重試」,請求是否冪等由網路層另外判斷。 -- `messageArgs` 只放數字、enum 之類的值;伺服器的訊息原文放 `cause`,只進 log。 +- `messageArgs` 的型別只收 `ErrorMessageArg` → 整數;伺服器的訊息原文放 `cause`,只進 + log。 - 每一列對應表配一個以錄下的錯誤回應 fixture 寫的契約測試,斷言轉出的子類 (ADR 0013 §如何確認)。 @@ -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` § 字串。 ## 加欄位 diff --git a/.trellis/spec/app/settings/index.md b/.trellis/spec/app/settings/index.md index 86e05c8c..1c243f68 100644 --- a/.trellis/spec/app/settings/index.md +++ b/.trellis/spec/app/settings/index.md @@ -18,7 +18,9 @@ 這個 `map` 裡套用(`resolveAppearance` 這類純函式),不寫回資料庫。 - 對外的值型別同時帶「生效值」與 `stored`(使用者設定過的值),設定頁用 `stored` 的 `null` 顯示「跟隨系統/預設」。 -- 設定方法一個欄位一個,只寫那個欄位(`repository.write(themeMode: ...)`)。 +- 設定方法一個欄位一個,只寫那個欄位(`repository.write(themeMode: ...)`)。參數可空, + `null` 是「清回沒設定過」,走 `repository.clear(themeMode: true)`:`write` 的 `null` + 表示「沒給、不動」,兩者不能共用一個方法。 ## 加一個設定欄位 @@ -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`,其他欄位不動,讀到的回到預設。 ## 測試寫法 @@ -52,4 +56,4 @@ - 表的新欄位是 `nullable()`,migration 沒有填預設值。 - 預設值沒有出現在 `lib/data/`。 -- 上面第 6 步的四種測試都在。 +- 上面第 6 步的五種測試都在。 diff --git a/.trellis/spec/app/ui/index.md b/.trellis/spec/app/ui/index.md new file mode 100644 index 00000000..6c9a677c --- /dev/null +++ b/.trellis/spec/app/ui/index.md @@ -0,0 +1,123 @@ +# 介面(`app/lib/ui/`、`app/lib/i18n/`) + +寫畫面、加字串、跳提示時適用。規則(token、字串來源、提示入口)與閘門見 +`app/AGENTS.md` § 介面;為什麼是這些選擇,見 ADR 0023、ADR 0024 與 +`.trellis/tasks/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 build_runner build`(和 drift 一起產生),提交 `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 測試。 From d371c680a77eff9d2ae7388c071d882dda933a53 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 18:50:01 +0800 Subject: [PATCH 4/8] chore(task): archive ui-foundation and plan app-shell --- .../09-28-m1-skeleton-tracer/implement.md | 6 +- .../tasks/09-28-m1-skeleton-tracer/task.json | 4 +- .trellis/tasks/09-30-app-shell/check.jsonl | 0 .../tasks/09-30-app-shell/implement.jsonl | 0 .trellis/tasks/09-30-app-shell/prd.md | 56 ++++++++++ .trellis/tasks/09-30-app-shell/task.json | 26 +++++ .../2026-09/09-30-ui-foundation/check.jsonl | 10 ++ .../09-30-ui-foundation/implement.jsonl | 9 ++ .../2026-09/09-30-ui-foundation/prd.md | 67 +++++++++++ .../09-30-ui-foundation/research/notes.md | 104 ++++++++++++++++++ .../2026-09/09-30-ui-foundation/task.json | 26 +++++ 11 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 .trellis/tasks/09-30-app-shell/check.jsonl create mode 100644 .trellis/tasks/09-30-app-shell/implement.jsonl create mode 100644 .trellis/tasks/09-30-app-shell/prd.md create mode 100644 .trellis/tasks/09-30-app-shell/task.json create mode 100644 .trellis/tasks/archive/2026-09/09-30-ui-foundation/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-30-ui-foundation/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-30-ui-foundation/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md create mode 100644 .trellis/tasks/archive/2026-09/09-30-ui-foundation/task.json diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index e28a4ccc..d4c0ed85 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -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 執行;逾時先送存活探測,沒回應才停用到重啟; @@ -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 動到播放時順手)。 @@ -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`; diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 30e8e242..555e3115 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -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": [], diff --git a/.trellis/tasks/09-30-app-shell/check.jsonl b/.trellis/tasks/09-30-app-shell/check.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-30-app-shell/implement.jsonl b/.trellis/tasks/09-30-app-shell/implement.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-30-app-shell/prd.md b/.trellis/tasks/09-30-app-shell/prd.md new file mode 100644 index 00000000..892274cb --- /dev/null +++ b/.trellis/tasks/09-30-app-shell/prd.md @@ -0,0 +1,56 @@ +# 外殼、搜尋、設定與播放列(M1 PR 12b) + +父任務:`../09-28-m1-skeleton-tracer`(implement「12.」的後半;擁有者決定 2)。在 12a(UI 基礎)合併之後開始。 + +依據: +- ADR 0024:§決定 3、5、6、8; +- ADR 0023:§決定 2 的位置與底部位移; +- ADR 0018:`PlaybackController` 是唯一播放入口; +- ADR 0027:實機驗證。 + +## 做什麼 + +1. **外殼**:「搜尋」「設定」兩個導覽項,依 `WindowClass` 切換樣式。 + - compact 用底部導覽列;medium、expanded 用 NavigationRail;large 以上用常駐導覽抽屜。 + - 右側「正在播放」面板、播放頁都是 M2,不做。 + - 外殼發佈「底部被佔用的高度」給 `ToastHost`。 +2. **搜尋頁**: + - 輸入框,加上已安裝且有 `search` 能力的插件 chip 列。chip 列可橫向捲動,兩端有漸層提示。 + - 結果列表:封面、曲名、上傳者、時長;支援分頁或「載入更多」。 + - 點一首就把這份結果列表交給 `PlaybackController`,從那一首開始依序播。 + - 錯誤經 `Toaster.error`;沒有插件、沒有結果、載入中都要有狀態畫面。 + - 數字用 `NumberFormat.compact`;M1 只有時長的話就不用。 +3. **設定頁**:只有外觀組,包含主題模式、語言,都可選「跟隨系統」。 + - expanded 以上用 list-detail,左分組、右內容;M1 只有一組也照這個版面。 +4. **播放列**:封面、曲名、上傳者、上一首、播放暫停、下一首、進度條(可拖動 seek)。 + - 三段寬度的控制項集合照 ADR 0024 §決定 5,只放 M1 有的功能。< 600 只有播放與下一首。 + - 曲名寬度不小於 160dp。 + - 狀態來自 `PlaybackController`:`Loading`、`Buffering` 顯示載入中;`Failed` 經 `Toaster`。 + - 點空白處開播放頁是 M2,M1 不接。 +5. **快捷鍵**:只在 FMP 在前景、焦點不在輸入框時有效。 + - 空白鍵:播放暫停; + - Ctrl+←/→:上一首、下一首; + - Shift+←/→:倒轉、快轉 5 秒; + - Ctrl+F:搜尋; + - Ctrl+,:設定; + - F6:在導覽、內容、播放列三區之間移動焦點。 + - 三區用 `FocusTraversalGroup`,Tab 只在區內移動。只有圖示的按鈕都要有 tooltip 與語意標籤,tooltip 附上按鍵。 +6. **收尾**: + - 身分頁移除,flavor 與資料目錄改記在 log 的啟動紀錄。 + - `--fmp-dev-playback` 入口刪除(PR 10 說好的),`--fmp-dev-plugin` 保留。 + - `verify-on-device` skill 與 `.trellis/spec/app/playback/index.md` 裡跟身分頁、dev 播放有關的步驟同步改掉,spec 改成指向 skill。 +7. **golden**:用 `alchemist`,色塊字型,只做播放列三段寬度,守版面結構。 + +## 驗收 + +- [ ] `app/` 驗證清單全過。 +- [ ] widget 測試: + - 外殼在五個寬度的導覽樣式; + - 淺色與深色主題下,搜尋、設定、播放列通過點擊區與對比度 guideline; + - 快捷鍵:輸入框內空白鍵只輸入空格、F6 在三區移動、Ctrl+F 與 Ctrl+, 會導覽; + - 播放列三段寬度的控制項集合,曲名 ≥ 160dp; + - 點搜尋結果時,`PlaybackController` 收到整份清單與起點。 +- [ ] 實機,照 `verify-on-device`: + - Android 與 Windows 各一次,**搜尋 B 站並從一首開始連續播完兩首**,屬真實連線、最少操作。這是 M1 端到端的驗收。 + - 用測試插件驗 UI 與提示:提示在對話框之上也看得到。 + - Windows Narrator 開著時送出提示,無障礙樹不凍結(`msaa_tree.ps1` 的節點數)。 diff --git a/.trellis/tasks/09-30-app-shell/task.json b/.trellis/tasks/09-30-app-shell/task.json new file mode 100644 index 00000000..161be77e --- /dev/null +++ b/.trellis/tasks/09-30-app-shell/task.json @@ -0,0 +1,26 @@ +{ + "id": "app-shell", + "name": "app-shell", + "title": "外殼、搜尋、設定與播放列", + "description": "M1 PR 12b: adaptive shell, search page, appearance settings, player bar, shortcuts and F6, end-to-end Bilibili playback", + "status": "planning", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-30", + "completedAt": null, + "branch": null, + "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/.trellis/tasks/archive/2026-09/09-30-ui-foundation/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/check.jsonl new file mode 100644 index 00000000..5854e531 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/check.jsonl @@ -0,0 +1,10 @@ +{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Tokens, breakpoints, fonts, i18n, accessibility"} +{"file": "docs/adr/0023-unified-toast.md", "reason": "Toaster and ToastHost"} +{"file": "docs/adr/0013-unified-error-model.md", "reason": "Error message mapping"} +{"file": "docs/adr/0011-settings-and-logging.md", "reason": "Appearance settings"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "fmp_design_tokens, fmp_toast_entry"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError fields"} +{"file": ".trellis/spec/app/settings/index.md", "reason": "Settings notifier and repository"} +{"file": ".trellis/spec/app/platform/index.md", "reason": "Font fallback capability"} +{"file": ".trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md", "reason": "Font facts incl. Android glyph behaviour"} +{"file": ".trellis/spec/app/ui/index.md", "reason": "Tokens, strings, toasts: how-to added in 12a"} diff --git a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/implement.jsonl new file mode 100644 index 00000000..dabd07cf --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/implement.jsonl @@ -0,0 +1,9 @@ +{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Tokens, breakpoints, fonts, i18n, accessibility"} +{"file": "docs/adr/0023-unified-toast.md", "reason": "Toaster and ToastHost"} +{"file": "docs/adr/0013-unified-error-model.md", "reason": "Error message mapping"} +{"file": "docs/adr/0011-settings-and-logging.md", "reason": "Appearance settings"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "fmp_design_tokens, fmp_toast_entry"} +{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError fields"} +{"file": ".trellis/spec/app/settings/index.md", "reason": "Settings notifier and repository"} +{"file": ".trellis/spec/app/platform/index.md", "reason": "Font fallback capability"} +{"file": ".trellis/tasks/archive/2026-09/09-29-platform-layer/research/notes.md", "reason": "Font facts incl. Android glyph behaviour"} diff --git a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/prd.md b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/prd.md new file mode 100644 index 00000000..3ede437d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/prd.md @@ -0,0 +1,67 @@ +# UI 基礎:主題、字串、提示(M1 PR 12a) + +父任務:`../09-28-m1-skeleton-tracer`(implement「12.」的前半;擁有者決定 2)。PR 12 拆成 12a(基礎)與 12b(外殼與頁面),各自一個 PR。 + +依據: +- ADR 0024:§決定 1–3、7、8 的無障礙部分; +- ADR 0023:§決定 1–3、5; +- ADR 0013:錯誤對應表、`messageKey`; +- ADR 0011:外觀設定; +- ADR 0009:字型 fallback 由平台層提供。 + +## 做什麼 + +1. **主題**:放在 `lib/ui/theme/`。 + - Material 3,seed 色;淺色、深色、跟隨系統。 + - `AppTokens`(`ThemeExtension`): + - 間距 4、8、12、16、20、24、32、40、48; + - 圓角 4、8、12、16、28; + - 語意色:成功、警告; + - 焦點框:2dp `primary`,外擴 2dp。 + - 元件固定尺寸放 `AppLayout`;字級只用 M3 的 `textTheme` 角色。 + - `fmp_design_tokens` 已經守著 `lib/ui/`(theme 目錄除外),這次讓它真的有東西可守。 +2. **`WindowClass`**:compact、medium、expanded、large、extraLarge,與 M3 同值(< 600、600–839、840–1199、1200–1599、≥ 1600)。 + - 以內容區寬度判斷,由一個 provider 或 `InheritedWidget` 提供。 +3. **字型**:主題的 `fontFamilyFallback` 取自平台層(PR 4 已提供),依目前介面語言排序。 + - `MaterialApp.locale` 帶 script 的形式,例如 `zh-Hant-TW`。 + - 做完後實測兩件事: + - Windows 的繁中是不是由正黑體顯示; + - Android 英文介面時,漢字會不會落到簡中字形。 + + 結論寫進研究檔;需要的話在漢字文字上指定 `zh-Hant`。 +4. **i18n**:slang。 + - `base_locale: zh-TW`,三語言:zh-TW、zh-CN、en;缺字退回繁中。 + - 測試:三語言的 key 集合相同,少一條就紅。 + - 外觀設定的「語言」控制 App locale:跟隨系統,或指定三者之一。 + - **錯誤訊息**:`ErrorMessageKey` 對到 slang 字串。 + - 同時收窄 `AppError.messageArgs`:只收數字,與已知的具名值(例如秒數、插件名稱),不讓插件把伺服器原文送上畫面。這是 PR 7 審查時發現的。 +5. **提示**:`Toaster` 與 `ToastHost`,照 ADR 0023 §決定 1–3、5。 + - `success`、`info`、`warning` 收 i18n 字串;`error(AppError, {operation})` 只收 `AppError`。 + - SnackBar 使用 floating 樣式,四種語意色加圖示。 + - 一次一則,新的取代舊的。時長:成功與資訊 4 秒,錯誤與警告 6 秒。開啟無障礙導覽時停留到手動關閉。 + - 5 秒內去重。 + - 放在 `MaterialApp.builder`,全螢幕頁、對話框、底部面板之上都看得到。 + - 外殼要發佈「底部被佔用的高度」。12a 先提供介面,12b 的外殼再接上。 + - 錯誤一律經 log 門面寫進錯誤歷史。 + - **M1 不做**:ADR 0023 §決定 4 的詳細頁、`ErrorReport`、「回報」按鈕。移到 M3,和 Debug 頁的錯誤歷史一起做,因為兩者共用同一頁。 +6. **外觀設定能清回「跟隨系統」**:`AppearanceSettingsRepository.write` 補上把欄位清成 `null` 的方式,並加測試。這是 PR 6 審查時發現的。 +7. `MaterialApp` 接上主題、locale 與 `ToastHost`。現有的身分頁保留,12b 才換成外殼。 + +## 驗收 + +- [ ] `app/` 驗證清單全過。 +- [ ] 測試: + - token 與 `WindowClass` 的邊界值; + - 三語言 key 集合相同; + - 每個 `ErrorMessageKey` 在三語言都有字串; + - `messageArgs` 收窄後,插件送來的字串不會出現在畫面上; + - Toast 的去重、取代、時長; + - 全螢幕路由與對話框開著時,提示仍然可見; + - 外觀設定可以清回跟隨系統; + - 淺色與深色主題下,示範畫面通過點擊區與對比度 guideline。 +- [ ] 實機,照 `verify-on-device`,Android 與 Windows 各一次: + - 切換主題、語言後身分頁正常; + - Windows 繁中是正黑體(截字形,避開個人資訊); + - Android 英文介面的漢字字形。 + + 身分頁目前不需要提示;Toast 的實機可見性在 12b 驗。 diff --git a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md new file mode 100644 index 00000000..c7ee670e --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md @@ -0,0 +1,104 @@ +# UI 基礎(M1 PR 12a):查證與選擇 + +查證日期 2026-09-30,對照 Flutter 3.47.5(engine `af7e796e16`)、`material_ui` 1.5.0、slang 4.19.2。 +這個環境沒有 context7/tavily,來源是 pub-cache 裡的套件原始碼與 README、pub.dev API(版本)。 + +## 1. 套件 + +| 套件 | 版本 | 用途 | 決定 | +|---|---|---|---| +| `slang` | ^4.19.2(pub.dev 最新,2026-09-12) | 介面字串 | 採用;ADR 0024 §決定 7 指定 | +| `slang_build_runner` | ^4.19.0(最新;它釘 `slang >=4.19.0 <4.20.0`) | 讓 `dart run build_runner build` 一併產生翻譯 | dev 依賴;CI 既有的「Check generated code is up to date」就涵蓋 slang,不另加步驟 | +| `slang_flutter` | — | `TranslationProvider`、`context.t`、`LocaleSettings` 的 Flutter 部分 | **不用**:翻譯以 Riverpod 注入(§3) | +| `clock` | ^1.1.3(原本就是 fake_async/flutter_test 的傳遞依賴) | Toast 去重讀時間 | 採用:`fake_async` 的 `run` 以 `withClock` 包住,`clock.now()` 跟著假時間走 | +| `intl` | ^0.20.2(跟 `material_ui` 的限制) | slang 產生檔的 import;之後 ADR 0024 §決定 6 的數字縮寫 | 直接依賴:產生檔一律 import 它,不宣告就是 import 沒宣告的套件(check agent 改) | +| `alchemist` | — | golden | 12a 不做 golden(ADR 0024 的 golden 是播放頁與播放列,12b 以後) | +| `flutter_localizations` | — | — | **不直接用**:見 §2 | + +## 2. Material 的內建字串與 locale + +- `material_ui` README「Step 2: Migrate localizations」:用 `material_ui` 自己的 + `GlobalMaterialLocalizations.delegates`(內含 `cupertino_ui` 的 Cupertino 與 + `flutter_localizations` 的 Widgets)。`flutter_localizations` 的 `GlobalMaterialLocalizations` + 提供的是凍結的 `package:flutter/material.dart` 的 `MaterialLocalizations` 型別, + `material_ui` 的元件查不到。 +- `material_ui-1.5.0/lib/src/l10n/generated_material_localizations.dart` 的 + `getMaterialTranslation`:`zh` 先看 `scriptCode`(`Hans` → `ZhHans`;`Hant` 再看 + `HK`/`TW`),沒有 script 才看 `countryCode`。`isSupported` 只看 `languageCode`。 +- `WidgetsApp` 即使給了 `locale` 也會拿它對 `supportedLocales` 做 + `basicLocaleListResolution`;`supportedLocales` 放同樣帶 script 的三個 locale,完全比對。 +- 測試(`test/ui/i18n/ui_locale_test.dart`):`zh-Hant-TW` 的 `okButtonLabel` 是「確定」、 + `zh-Hans-CN` 是「确定」、`en` 是「OK」。 + +## 3. slang 的 locale 與注入方式 + +- 檔名 `.i18n.json`;`zh-TW`、`zh-CN`、`en` 產生 `AppLocale.zhTw`(`languageCode: 'zh', + countryCode: 'TW'`)等。slang 的 locale 也可以帶 script(`zh-Hant-TW`),但 ADR 0024 定 + `base_locale: zh-TW`,所以 slang 用 `zh-TW`/`zh-CN`,給 Flutter 的帶 script 的 locale 由 + `lib/ui/i18n/ui_locale.dart` 的 `flutterLocaleOf` 從 `LocaleSetting` 對出來。slang 的 + `AppLocale.flutterLocale` 不用(`flutter_integration: false` 時也不產生)。 +- README「Dependency Injection」:`locale_handling: false`(不產生全域 `t`、`LocaleSettings`)+ + `translation_class_visibility: public`,以 `AppLocale.x.buildSync()` 建實例,文件的例子就是 + Riverpod。採用:`translationsProvider` 由 `uiLocaleProvider` 算出。理由:語言的來源已經是 + 外觀設定的 Notifier(ADR 0011),再有 slang 的全域 `LocaleSettings` 會變成兩份狀態; + `Toaster` 不用 `BuildContext` 也拿得到翻譯;測試不用重設全域。 +- `fallback_strategy: base_locale`:缺字退回繁中(PRD「缺字退回繁中」)。代價是漏翻照樣編譯, + 所以 `test/i18n/translations_test.dart` 比對三個 JSON 的 key 與參數。 +- `lazy: false`:Android、Windows 沒有 deferred loading,延遲載入換不到任何東西(舊專案同樣設定)。 +- `string_interpolation: braces`(`{source}`)、`timestamp: false`(產生檔穩定,CI 才比得了)、 + `flat_map: false`(不用字串 key 查翻譯)、`format.enabled: true, width: 80`(產生檔通過 + `dart format --set-exit-if-changed`)。 +- 產生檔提交(`lib/i18n/*.g.dart`),比照 drift:拉下來不用先跑 codegen;CI 重跑 build_runner + 後有 diff 就紅。 + +## 4. Toast 的層級(ADR 0023 §決定 3) + +- `MaterialApp` 自己在 `builder` 之上建了一個根 `ScaffoldMessenger`;`ToastHost` 在 `builder` + 裡再建一個,包住 Navigator。頁面的 `Scaffold` 都是宿主 `Scaffold` 的子孫, + `ScaffoldMessengerState` 只把 SnackBar 放在「根」的 Scaffold(不是其他已登記 Scaffold 的 + 子孫者),所以只出現在宿主這一層,畫在所有路由之上。 +- SnackBar 的計時器只在 `ModalRoute.of(messenger 的 context)` 為 null 或是目前路由時啟動 + (`scaffold.dart` 的 `ScaffoldMessengerState.build`);宿主在 Navigator 之上,`ModalRoute` 是 + null,全螢幕頁開著時照樣計時。 +- `SnackBar.persist` 預設 `action != null`:帶動作的預設不消失。ADR 要照時長消失,所以明確給 + `persist: accessibleNavigation`。 +- floating 的 SnackBar 已由 `Scaffold` 放在底部安全區(`minViewPadding.bottom`)之上;外殼發佈 + 的高度從視窗底邊算,所以宿主只補 `max(0, 發佈高度 − viewPadding.bottom)`。 +- **無障礙導覽時的關閉鈕需要 Overlay**:`IconButton` 的 tooltip 找最近的 Overlay,宿主在 + Navigator 之上找不到而拋錯(widget 測試抓到)。宿主以 `Overlay.wrap` 包住自己。 +- 宿主 `Scaffold` 設 `resizeToAvoidBottomInset: false`:否則鍵盤出現時整個 Navigator 被縮, + 頁面的 Scaffold 也拿不到 `viewInsets`。 + +## 5. 採用的慣例 + +- 間距以 4dp 為單位、名稱是倍數(`x4` = 16):Tailwind 的 spacing scale 與 M3 的 4dp grid。 +- 圓角名稱:M3 shape scale(extraSmall 4、small 8、medium 12、large 16、extraLarge 28)。 +- 語意色:M3「custom colors」的做法,以固定 seed 產生同一組四個色調(color/on/container/ + onContainer);沒做 harmonize(要直接依賴 `material_color_utilities`)。 +- 資訊類提示用 M3 snackbar 預設的 `inverseSurface`;成功、警告、錯誤用各自的 container 色。 +- 語言選單的名稱用各語言自己的寫法(endonym):Android、iOS 語言設定的慣例;每個名稱帶自己的 + locale,繁簡字形不隨介面語言變。 +- `WindowClass` 的值與名稱:M3 window size classes。 +- 翻譯注入:slang 文件的 Dependency Injection 一節(Riverpod 例子)。 + +## 6. 實機驗證(主對話做,結論補在這裡) + +- Windows 繁中是不是正黑體(2026-09-30,Windows dev build,身分頁依序切 繁體中文 → English → + 简体中文):繁中與英文介面的「UI」列與 zh-Hant 參照列(正黑體)相同,簡中介面與 zh-Hans + 參照列(雅黑)相同。 + - 同一輪發現 `UI locale applied` 的 `fontFallback` 記成上一個語言的清單(簡中記成英文的四個 + 字型);畫面本身正確。原因:listener 在 `uiLocaleProvider` 的 listener 裡 `ref.read` + `fontFamilyFallbackProvider`,而 Riverpod 依訂閱順序通知,`fontFamilyFallbackProvider` + 重建一次後重新訂閱、排到 listener 後面,被讀的時候還沒收到「相依變了」,回傳舊值。改成 + 以 listener 拿到的語言呼叫 `fontFamilyFallbackOf`;`fmp_app_test.dart` 來回切換四次斷言 + 每筆 log。 +- Android 英文介面的漢字字形(2026-09-30,Android 17 模擬器,系統語言 en、App 語言跟隨系統, + log `{locale: en, fontFallback: []}`):「UI」列是簡中字形(「骨」裡面是直的「月」、「令」 + 末筆是點),與 zh-Hans 參照列相同。也就是英文介面的文字 locale 是 `en`,引擎依 + fonts.xml 的順序取到 `zh-Hans` 那個 family。 + - 對策:`buildAppTheme` 把 `textLocaleOf` 的 locale 放進每個 `TextTheme` 角色(英文介面是 + `zh-Hant-TW`,中文介面同介面語言)。Material 元件(文字、輸入框、按鈕、分段按鈕、 + ListTile、AppBar)的樣式都從主題繼承到它(`app_theme_test.dart`);英文字由主字型顯示, + 不受影響;Material 內建字串仍看 `MaterialApp.locale` 的 `en`。 + - 代價:樣式的 locale 蓋過 `Text.locale`,所以語言選單的名稱改在樣式上給 locale。 + - 修正後的實機複驗(2026-09-30,主對話):Android 模擬器(系統語言 en)英文介面的漢字與 zh-Hant 對照列相同(「骨」內為台灣寫法、「令」末筆非點),切到简体中文後與 zh-Hans 列相同;Windows 切 繁中 → English → 简中 → 繁中,四筆 `UI locale applied` 的 `fontFallback` 都對應當下語言,字形與修正前相同(繁中/English 正黑體、简中雅黑)。 diff --git a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/task.json b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/task.json new file mode 100644 index 00000000..fc06f49b --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/task.json @@ -0,0 +1,26 @@ +{ + "id": "ui-foundation", + "name": "ui-foundation", + "title": "UI 基礎:主題、字串、提示", + "description": "M1 PR 12a: AppTokens/AppLayout, WindowClass, font fallback, slang three locales, Toaster/ToastHost, AppError message mapping", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-30", + "completedAt": "2026-09-30", + "branch": "feat/ui-foundation", + "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 From 9d42fa8a6c17d7b6a4046d10bf3bcd54afaab1e7 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 18:52:49 +0800 Subject: [PATCH 5/8] docs(app): point the ui spec at the archived research --- .trellis/spec/app/ui/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trellis/spec/app/ui/index.md b/.trellis/spec/app/ui/index.md index 6c9a677c..21e71a25 100644 --- a/.trellis/spec/app/ui/index.md +++ b/.trellis/spec/app/ui/index.md @@ -2,7 +2,7 @@ 寫畫面、加字串、跳提示時適用。規則(token、字串來源、提示入口)與閘門見 `app/AGENTS.md` § 介面;為什麼是這些選擇,見 ADR 0023、ADR 0024 與 -`.trellis/tasks/09-30-ui-foundation/research/notes.md`。這裡只寫怎麼做。 +`.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md`。這裡只寫怎麼做。 ## 目錄 From 4a902872aa012797541cef9bcf354dc0e1e90919 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 19:12:53 +0800 Subject: [PATCH 6/8] ci: let the app codegen check overwrite committed outputs A fresh checkout has no build_runner asset graph, so committed slang outputs are treated as conflicts and the build aborts; git still decides whether anything changed. --- .claude/skills/verify-on-device/SKILL.md | 2 +- .github/workflows/ci.yml | 6 ++++-- .trellis/spec/app/data/index.md | 4 ++-- .trellis/spec/app/ui/index.md | 2 +- app/AGENTS.md | 6 +++--- app/build.yaml | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.claude/skills/verify-on-device/SKILL.md b/.claude/skills/verify-on-device/SKILL.md index 858e3827..72881013 100644 --- a/.claude/skills/verify-on-device/SKILL.md +++ b/.claude/skills/verify-on-device/SKILL.md @@ -35,7 +35,7 @@ description: >- - 只用 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`)。 + `dart run build_runner build --delete-conflicting-outputs`)。 - Git Bash 會改寫 `/data/...` 這類路徑:`adb shell` 前加 `MSYS_NO_PATHCONV=1`。Python 單行指令前加 `PYTHONIOENCODING=utf-8`。 - 有 Orca 時,`orca skills get computer-use`/`orca-cli` 取得與版本相符的指令參考;不靠記憶。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56ac4d6c..c5a282d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,12 +233,14 @@ jobs: # 路徑用 `.`:這個 job 的工作目錄已經是 app/。 - name: Check generated code is up to date run: | - dart run build_runner build + # 乾淨的 checkout 沒有 build_runner 的資產圖,已提交的產生檔會被當成 + # 衝突而拒寫;允許覆寫後,再用 git 判斷內容有沒有變。 + dart run build_runner build --delete-conflicting-outputs 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' in app/ and commit the result." exit 1 fi diff --git a/.trellis/spec/app/data/index.md b/.trellis/spec/app/data/index.md index 9f5de7a5..7f482ac5 100644 --- a/.trellis/spec/app/data/index.md +++ b/.trellis/spec/app/data/index.md @@ -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.json`; - 產生 `lib/data/database/app_database.steps.dart`(`stepByStep`); @@ -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。 diff --git a/.trellis/spec/app/ui/index.md b/.trellis/spec/app/ui/index.md index 21e71a25..9355f8ae 100644 --- a/.trellis/spec/app/ui/index.md +++ b/.trellis/spec/app/ui/index.md @@ -59,7 +59,7 @@ switch (WindowClass.of(context)) { 加一條字串: 1. 三個 JSON 都加同一個 key,先寫繁中。參數寫 `{name}`,三個語言的參數要一樣。 -2. `dart run build_runner build`(和 drift 一起產生),提交 `lib/i18n/*.g.dart`。 +2. `dart run build_runner build --delete-conflicting-outputs`(和 drift 一起產生),提交 `lib/i18n/*.g.dart`。 3. widget 裡 `final t = ref.watch(translationsProvider);`,`t.section.key` 或 `t.section.key(name: …)`。沒有 slang 的全域 `t`、`context.t`。 diff --git a/app/AGENTS.md b/app/AGENTS.md index 2036985a..d3d4ff73 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -12,8 +12,8 @@ | 任何改動 | `dart format --output=none --set-exit-if-changed .`、`dart analyze --fatal-infos`、`flutter analyze`、`flutter test` | | `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | | 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | -| drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | -| 翻譯(`lib/i18n/*.i18n.json`)或 `build.yaml` 的 slang 設定 | 先 `dart run build_runner build`,再跑第一列 | +| drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build --delete-conflicting-outputs`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | +| 翻譯(`lib/i18n/*.i18n.json`)或 `build.yaml` 的 slang 設定 | 先 `dart run build_runner build --delete-conflicting-outputs`,再跑第一列 | | 播放後端(`lib/playback/backends/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/audio_backend_contract_test.dart -d <裝置>`(見 § 播放) | - `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 @@ -127,7 +127,7 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 閘門:`test/data/database/app_database_test.dart` 與 repository 測試的 cascade 案例。 - drift 與 slang(§ 介面)產生的 `*.g.dart` 提交進 repo(`app/.gitignore` 覆寫根目錄對 `*.g.dart` 的忽略),拉下來不用先跑 codegen。改了 table、`@DriftDatabase` 或翻譯檔就重跑 - `dart run build_runner build`(兩者一起產生)並提交產生檔。閘門:CI `app` job 的 + `dart run build_runner build --delete-conflicting-outputs`(兩者一起產生)並提交產生檔。閘門:CI `app` job 的 「Check generated code is up to date」;本機沒有東西擋。在 Windows 上重跑會把產生檔與 `linux/`、`macos/`、`windows/` 的 plugin registrant 改成 LF,內容沒變的(`git diff --ignore-all-space --ignore-cr-at-eol` 為空)直接還原。 diff --git a/app/build.yaml b/app/build.yaml index 24a9e6e3..2c7a61b3 100644 --- a/app/build.yaml +++ b/app/build.yaml @@ -1,4 +1,4 @@ -# 程式碼產生器設定;`dart run build_runner build` 一次跑完兩者,產生檔都提交 +# 程式碼產生器設定;`dart run build_runner build --delete-conflicting-outputs` 一次跑完兩者,產生檔都提交 # (app/AGENTS.md § 資料層、§ 介面)。 targets: $default: From b667790aad6a2c5059bfbce9cc52b8d223a14630 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 19:34:42 +0800 Subject: [PATCH 7/8] build(app): generate translations with the slang cli slang_build_runner treats committed outputs as conflicts in a fresh checkout even with --delete-conflicting-outputs; the slang CLI reads slang.yaml and rewrites them in place. --- .github/workflows/ci.yml | 11 +++++------ app/build.yaml | 24 ++---------------------- app/pubspec.lock | 8 -------- app/pubspec.yaml | 3 --- app/slang.yaml | 20 ++++++++++++++++++++ 5 files changed, 27 insertions(+), 39 deletions(-) create mode 100644 app/slang.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a282d3..6cb17b27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,20 +227,19 @@ jobs: - name: Check formatting run: dart format --output=none --set-exit-if-changed . - # drift 與 slang 產生的 *.g.dart 有提交(app/AGENTS.md § 資料層)。重跑 - # codegen 後 app/ 有任何變動或新檔,就是改了 table 或翻譯檔卻沒重跑 - # build_runner(slang_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: | - # 乾淨的 checkout 沒有 build_runner 的資產圖,已提交的產生檔會被當成 - # 衝突而拒寫;允許覆寫後,再用 git 判斷內容有沒有變。 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 --delete-conflicting-outputs' 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 diff --git a/app/build.yaml b/app/build.yaml index 2c7a61b3..eef0efa1 100644 --- a/app/build.yaml +++ b/app/build.yaml @@ -1,5 +1,5 @@ -# 程式碼產生器設定;`dart run build_runner build --delete-conflicting-outputs` 一次跑完兩者,產生檔都提交 -# (app/AGENTS.md § 資料層、§ 介面)。 +# build_runner 的設定(drift);產生檔提交(app/AGENTS.md § 資料層)。翻譯不走 +# build_runner,設定在 slang.yaml。 targets: $default: builders: @@ -11,23 +11,3 @@ targets: options: databases: app_database: lib/data/database/app_database.dart - # slang(ADR 0024 §決定 7)。選項的理由見 .trellis/spec/app/ui/index.md 開頭 - # 連到的研究檔。 - slang_build_runner: - options: - base_locale: zh-TW - fallback_strategy: base_locale - input_directory: lib/i18n - input_file_pattern: .i18n.json - output_directory: lib/i18n - output_file_name: strings.g.dart - lazy: false - locale_handling: false - flutter_integration: false - translation_class_visibility: public - string_interpolation: braces - timestamp: false - flat_map: false - format: - enabled: true - width: 80 diff --git a/app/pubspec.lock b/app/pubspec.lock index c835742f..4fcfcd6a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -857,14 +857,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.19.2" - slang_build_runner: - dependency: "direct dev" - description: - name: slang_build_runner - sha256: f2b6c5fc1f5bc41de80b05e95e7d5d03b5c3041082877ce931fc7ba61cd08afe - url: "https://pub.dev" - source: hosted - version: "4.19.0" source_gen: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 79c5cbed..497c194f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -76,9 +76,6 @@ dev_dependencies: # 插件執行環境的實機量測(integration_test/plugin_runtime_benchmark_test.dart)。 integration_test: sdk: flutter - # 讓 `dart run build_runner build` 一併產生 lib/i18n/ 的翻譯程式碼(設定在 - # build.yaml)。它釘 slang 的 minor 版本,兩者一起升。 - slang_build_runner: ^4.19.0 yaml: ^3.1.4 flutter: diff --git a/app/slang.yaml b/app/slang.yaml new file mode 100644 index 00000000..66d034f1 --- /dev/null +++ b/app/slang.yaml @@ -0,0 +1,20 @@ +# slang(ADR 0024 §決定 7),以 `dart run slang` 產生 lib/i18n/*.g.dart 並提交。 +# 不用 slang_build_runner:它的彙總輸出在乾淨的 checkout 裡會把已提交的產生檔 +# 當成衝突而拒寫(官方把它定位成「不提交產生檔、CI 時才產生」的做法)。 +# 選項的理由見 .trellis/spec/app/ui/index.md 開頭連到的研究檔。 +base_locale: zh-TW +fallback_strategy: base_locale +input_directory: lib/i18n +input_file_pattern: .i18n.json +output_directory: lib/i18n +output_file_name: strings.g.dart +lazy: false +locale_handling: false +flutter_integration: false +translation_class_visibility: public +string_interpolation: braces +timestamp: false +flat_map: false +format: + enabled: true + width: 80 From abed18c4e53faa4ef4a55d7444bc46dedb5f5653 Mon Sep 17 00:00:00 2001 From: 1morr Date: Wed, 30 Sep 2026 19:34:45 +0800 Subject: [PATCH 8/8] docs(app): regenerate translations with dart run slang --- .claude/skills/verify-on-device/SKILL.md | 4 ++-- .trellis/spec/app/ui/index.md | 2 +- .../2026-09/09-30-ui-foundation/research/notes.md | 2 +- app/AGENTS.md | 9 +++++---- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.claude/skills/verify-on-device/SKILL.md b/.claude/skills/verify-on-device/SKILL.md index 72881013..a065e6be 100644 --- a/.claude/skills/verify-on-device/SKILL.md +++ b/.claude/skills/verify-on-device/SKILL.md @@ -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 --delete-conflicting-outputs`)。 +- 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` 取得與版本相符的指令參考;不靠記憶。 diff --git a/.trellis/spec/app/ui/index.md b/.trellis/spec/app/ui/index.md index 9355f8ae..383e5f86 100644 --- a/.trellis/spec/app/ui/index.md +++ b/.trellis/spec/app/ui/index.md @@ -59,7 +59,7 @@ switch (WindowClass.of(context)) { 加一條字串: 1. 三個 JSON 都加同一個 key,先寫繁中。參數寫 `{name}`,三個語言的參數要一樣。 -2. `dart run build_runner build --delete-conflicting-outputs`(和 drift 一起產生),提交 `lib/i18n/*.g.dart`。 +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`。 diff --git a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md index c7ee670e..5e4f7297 100644 --- a/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md +++ b/.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md @@ -8,7 +8,7 @@ | 套件 | 版本 | 用途 | 決定 | |---|---|---|---| | `slang` | ^4.19.2(pub.dev 最新,2026-09-12) | 介面字串 | 採用;ADR 0024 §決定 7 指定 | -| `slang_build_runner` | ^4.19.0(最新;它釘 `slang >=4.19.0 <4.20.0`) | 讓 `dart run build_runner build` 一併產生翻譯 | dev 依賴;CI 既有的「Check generated code is up to date」就涵蓋 slang,不另加步驟 | +| ~~`slang_build_runner`~~ | 已移除(PR #190 的 CI 發現) | — | 乾淨 checkout 下它的彙總輸出把已提交的 `lib/i18n/*.g.dart` 當成衝突,`--delete-conflicting-outputs` 也一樣拒寫;官方把它定位成「不提交產生檔、CI 時才產生」。改用 `dart run slang`,設定移到 `slang.yaml`,CI 另跑一次再看 `git status` | | `slang_flutter` | — | `TranslationProvider`、`context.t`、`LocaleSettings` 的 Flutter 部分 | **不用**:翻譯以 Riverpod 注入(§3) | | `clock` | ^1.1.3(原本就是 fake_async/flutter_test 的傳遞依賴) | Toast 去重讀時間 | 採用:`fake_async` 的 `run` 以 `withClock` 包住,`clock.now()` 跟著假時間走 | | `intl` | ^0.20.2(跟 `material_ui` 的限制) | slang 產生檔的 import;之後 ADR 0024 §決定 6 的數字縮寫 | 直接依賴:產生檔一律 import 它,不宣告就是 import 沒宣告的套件(check agent 改) | diff --git a/app/AGENTS.md b/app/AGENTS.md index d3d4ff73..4159aaa4 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -13,7 +13,7 @@ | `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | | 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | | drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build --delete-conflicting-outputs`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | -| 翻譯(`lib/i18n/*.i18n.json`)或 `build.yaml` 的 slang 設定 | 先 `dart run build_runner build --delete-conflicting-outputs`,再跑第一列 | +| 翻譯(`lib/i18n/*.i18n.json`)或 `slang.yaml` | 先 `dart run slang`,再跑第一列 | | 播放後端(`lib/playback/backends/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/audio_backend_contract_test.dart -d <裝置>`(見 § 播放) | - `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 @@ -126,8 +126,9 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 外鍵每次開啟都在 `beforeOpen` 打開(SQLite 預設關、只對當前連線有效;ADR 0019 §決定 1)。 閘門:`test/data/database/app_database_test.dart` 與 repository 測試的 cascade 案例。 - drift 與 slang(§ 介面)產生的 `*.g.dart` 提交進 repo(`app/.gitignore` 覆寫根目錄對 - `*.g.dart` 的忽略),拉下來不用先跑 codegen。改了 table、`@DriftDatabase` 或翻譯檔就重跑 - `dart run build_runner build --delete-conflicting-outputs`(兩者一起產生)並提交產生檔。閘門:CI `app` job 的 + `*.g.dart` 的忽略),拉下來不用先跑 codegen。改了 table、`@DriftDatabase` 就重跑 + `dart run build_runner build --delete-conflicting-outputs`,改了翻譯檔就重跑 `dart run slang`(不用 + slang_build_runner:它在乾淨的 checkout 會把已提交的產生檔當成衝突),並提交產生檔。閘門:CI `app` job 的 「Check generated code is up to date」;本機沒有東西擋。在 Windows 上重跑會把產生檔與 `linux/`、`macos/`、`windows/` 的 plugin registrant 改成 LF,內容沒變的(`git diff --ignore-all-space --ignore-cr-at-eol` 為空)直接還原。 @@ -392,7 +393,7 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 所以編譯擋不住漏翻。閘門:`test/i18n/translations_test.dart`(三個語言的 key 與 `{參數}` 相同、每個 `ErrorMessageKey`/`UnavailableReason` 都有字串;含變異案例)。widget 裡寫死的 字串沒有閘門,review 時看;身分頁的 `Dev playback:` 之類是開發用標籤,刻意不翻。 -- 翻譯只經 `translationsProvider`(`lib/ui/i18n/ui_locale.dart`)注入:`build.yaml` 設 +- 翻譯只經 `translationsProvider`(`lib/ui/i18n/ui_locale.dart`)注入:`slang.yaml` 設 `locale_handling: false`,slang 不產生全域 `t`/`LocaleSettings`,語言狀態只有外觀設定一份。 - `MaterialApp.locale` 一律給帶書寫系統的 locale(`zh-Hant-TW`、`zh-Hans-CN`、`en`),由 `flutterLocaleOf` 從 `LocaleSetting` 對出;它決定 Android 的繁簡字形與 Material 內建字串。