diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b35ffcf..7401770 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Set up patched Node.js + uses: actions/setup-node@v5 + with: + node-version-file: .node-version + + - name: Test Node.js runtime requirements + run: npm run test:node + - name: Set up Flutter uses: subosito/flutter-action@v2 with: @@ -242,6 +250,11 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Set up patched Node.js + uses: actions/setup-node@v5 + with: + node-version-file: .node-version + - name: Set up Flutter uses: subosito/flutter-action@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 951e075..7634f7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -211,6 +211,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + - name: Set up patched Node.js + uses: actions/setup-node@v5 + with: + node-version-file: .node-version + - name: Test Node.js runtime requirements + run: npm run test:node - name: Set up Flutter uses: subosito/flutter-action@v2 with: @@ -495,17 +501,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + - name: Set up patched Node.js + uses: actions/setup-node@v5 + with: + node-version-file: .node-version + cache: npm + - name: Install web deployment dependencies + run: npm ci - name: Download Web artifact uses: actions/download-artifact@v5 with: name: web-release path: build/web - name: Deploy Worker - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npm run deploy:web # ── Create GitHub Release ────────────────────────────────────── create-release: diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ce62013 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.22.1 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/README.md b/README.md index 3f26786..b471f91 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ https://github.com/YetAnotherBusDeveloper/yetanotherbusapp/releases/latest - Flutter 3.x+ - Dart SDK - Android Studio / VS Code +- Node.js 24.22.1(Web 工具;版本記錄於 `.node-version`) ### Clone @@ -150,6 +151,15 @@ cd yetanotherbusapp flutter pub get ``` +Web 開發/部署工具先切換至 `.node-version` 指定的 Node.js,再執行 `npm ci`。 +支援的安全版本為 Node.js `22.23.4+`、`24.22.1+`、`26.12.1+`(各自的主版本), +修補 [CVE-2026-111497 / HTTP2Shell](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)。 +安裝、Web 開發與部署命令會拒絕舊版 Node.js;只更新 npm 套件無法修補 Node 內建的解碼器。 +App 的 `brotli` 套件為純 Dart 實作,未連結此次受影響的原生 C 解碼器。 +`npm run serve:web` 改用 Flutter Web 開發伺服器(`http://127.0.0.1:8080`)。 +Wrangler 依賴的本機 `workerd` 仍內嵌舊版 Brotli,因此暫停以 `wrangler dev` +執行本機 Worker;Wrangler 僅用於部署,由指定的安全版 Node.js 執行。 + ### Run ```bash diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da8fa29..f8314de 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7,6 +7,47 @@ "settingsHistoryDescription": "Search history, clearing data and privacy policy", "settingsOnboardingDescription": "Run the first-time setup again", "settingsAboutDescription": "Version information, contributors, community and feedback", + "languageDescriptionCompact": "Change the app language immediately.", + "themeModeDescriptionCompact": "Use light, dark or system theme.", + "mapProviderDescriptionCompact": "Change the base map, keeping the same bus data.", + "wearSyncCategoryDescriptionCompact": "Choose favorites groups to sync to your watch.", + "normalUpdateIntervalDescriptionCompact": "Arrival refresh interval; shorter uses more battery.", + "retryIntervalDescriptionCompact": "Retry after failures; waits at least the normal interval.", + "appUpdateManualCheckDescriptionCompact": "Check the selected channel for a new version.", + "searchHistoryLimitDescriptionCompact": "Keep recent routes; 0 clears and disables history.", + "clearSearchHistoryDescriptionCompact": "Clear recent routes; keep favorites and learning data.", + "clearSmartHistoryDescriptionCompact": "Reset recommendation learning; keep favorites.", + "clearRouteSelectionHistoryDescriptionCompact": "Clear route and stop selections; keep route open counts.", + "termsOfServiceDescriptionCompact": "Read the service rules and terms.", + "privacyPolicyDescriptionCompact": "Read how location, account and usage data are handled.", + "restartOnboardingDescriptionCompact": "Repeat first-time setup; keep favorites and preferences.", + "interfaceScaleDescriptionCompact": "Adjust text and interface size.", + "showWeatherDescriptionCompact": "Show local weather on Home; tap for the forecast.", + "personalizationDescriptionCompact": "Customize colors, AMOLED black mode and backgrounds.", + "wearSyncDescriptionCompact": "Sync favorites to your connected watch.", + "alwaysShowSecondsDescriptionCompact": "Show seconds when available; arrivals are estimates.", + "hapticFeedbackDescriptionCompact": "Brief vibrations for supported actions.", + "smartRecommendationsDescriptionCompact": "Suggest routes based on your habits and usual times.", + "autoFavoriteDescriptionCompact": "Automatically add frequent stops to Frequent favorites.", + "smartNotificationDescriptionCompact": "Arrival alerts at usual times; needs notifications and background location.", + "keepScreenAwakeDescriptionCompact": "Keep the screen on while viewing a route.", + "backgroundTripDescriptionCompact": "Trip alerts while locked; needs notifications and location.", + "backgroundTripIosDescriptionCompact": "Trip status while locked; needs notifications and background location.", + "favoriteWidgetRefreshHelperCompact": "Refresh at least 15 minutes apart; the system may delay it.", + "updateChannelDeveloperDescriptionCompact": "Self-built version; update checks are disabled.", + "updateChannelNightlyDescriptionCompact": "Get new features earlier; may be less stable.", + "updateChannelReleaseDescriptionCompact": "Receive published release updates only.", + "updateCheckOffDescriptionCompact": "Skip startup checks; check manually instead.", + "updateCheckNotifyDescriptionCompact": "Check at startup and notify about updates.", + "updateCheckPopupDescriptionCompact": "Check at startup and open the update dialog if available.", + "personalizationBackgroundImageDescriptionCompact": "Set page backgrounds; Home applies to all transit home tabs.", + "personalizationColorSourceDescriptionCompact": "Auto uses background colors; System uses device colors.", + "personalizationGlobalHomeDescriptionCompact": "Apply to all transit home tabs.", + "adsLockedMessageCompact": "Too many toggles; ads are locked on.", + "adsDisabledStatus": "Ads are off.", + "personalizationAmoledDescriptionCompact": "Use a black background in dark mode.", + "personalizationHomeGradientDescriptionCompact": "Adjust the Home gradient opacity.", + "personalizationPerPageDescriptionCompact": "Set a background for each page.", "settingsTitle": "Settings", "appearanceSectionTitle": "Appearance", "languageLabel": "Language", @@ -41,7 +82,7 @@ "themeModeLight": "Light", "themeModeDark": "Dark", "compactModeTitle": "Compact mode", - "compactModeDescription": "Reduce supporting descriptions on the home screen and some cards to keep common actions together. Turn it off for fuller explanations. The desktop home screen always uses a compact layout.", + "compactModeDescription": "Turn off for full descriptions. Desktop Home stays compact.", "showWeatherTitle": "Show weather", "showWeatherDescription": "Show temperature and weather for your current location next to the home title. Tap it to open the full forecast. Available location and network information are required. Turn it off to hide weather from the title.", "mapProviderLabel": "Map provider", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4d0ec0a..b105ce9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -141,6 +141,252 @@ abstract class AppLocalizations { /// **'Version information, contributors, community and feedback'** String get settingsAboutDescription; + /// No description provided for @languageDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Change the app language immediately.'** + String get languageDescriptionCompact; + + /// No description provided for @themeModeDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Use light, dark or system theme.'** + String get themeModeDescriptionCompact; + + /// No description provided for @mapProviderDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Change the base map, keeping the same bus data.'** + String get mapProviderDescriptionCompact; + + /// No description provided for @wearSyncCategoryDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Choose favorites groups to sync to your watch.'** + String get wearSyncCategoryDescriptionCompact; + + /// No description provided for @normalUpdateIntervalDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Arrival refresh interval; shorter uses more battery.'** + String get normalUpdateIntervalDescriptionCompact; + + /// No description provided for @retryIntervalDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Retry after failures; waits at least the normal interval.'** + String get retryIntervalDescriptionCompact; + + /// No description provided for @appUpdateManualCheckDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Check the selected channel for a new version.'** + String get appUpdateManualCheckDescriptionCompact; + + /// No description provided for @searchHistoryLimitDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Keep recent routes; 0 clears and disables history.'** + String get searchHistoryLimitDescriptionCompact; + + /// No description provided for @clearSearchHistoryDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Clear recent routes; keep favorites and learning data.'** + String get clearSearchHistoryDescriptionCompact; + + /// No description provided for @clearSmartHistoryDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Reset recommendation learning; keep favorites.'** + String get clearSmartHistoryDescriptionCompact; + + /// No description provided for @clearRouteSelectionHistoryDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Clear route and stop selections; keep route open counts.'** + String get clearRouteSelectionHistoryDescriptionCompact; + + /// No description provided for @termsOfServiceDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Read the service rules and terms.'** + String get termsOfServiceDescriptionCompact; + + /// No description provided for @privacyPolicyDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Read how location, account and usage data are handled.'** + String get privacyPolicyDescriptionCompact; + + /// No description provided for @restartOnboardingDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Repeat first-time setup; keep favorites and preferences.'** + String get restartOnboardingDescriptionCompact; + + /// No description provided for @interfaceScaleDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Adjust text and interface size.'** + String get interfaceScaleDescriptionCompact; + + /// No description provided for @showWeatherDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Show local weather on Home; tap for the forecast.'** + String get showWeatherDescriptionCompact; + + /// No description provided for @personalizationDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Customize colors, AMOLED black mode and backgrounds.'** + String get personalizationDescriptionCompact; + + /// No description provided for @wearSyncDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Sync favorites to your connected watch.'** + String get wearSyncDescriptionCompact; + + /// No description provided for @alwaysShowSecondsDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Show seconds when available; arrivals are estimates.'** + String get alwaysShowSecondsDescriptionCompact; + + /// No description provided for @hapticFeedbackDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Brief vibrations for supported actions.'** + String get hapticFeedbackDescriptionCompact; + + /// No description provided for @smartRecommendationsDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Suggest routes based on your habits and usual times.'** + String get smartRecommendationsDescriptionCompact; + + /// No description provided for @autoFavoriteDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Automatically add frequent stops to Frequent favorites.'** + String get autoFavoriteDescriptionCompact; + + /// No description provided for @smartNotificationDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Arrival alerts at usual times; needs notifications and background location.'** + String get smartNotificationDescriptionCompact; + + /// No description provided for @keepScreenAwakeDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Keep the screen on while viewing a route.'** + String get keepScreenAwakeDescriptionCompact; + + /// No description provided for @backgroundTripDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Trip alerts while locked; needs notifications and location.'** + String get backgroundTripDescriptionCompact; + + /// No description provided for @backgroundTripIosDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Trip status while locked; needs notifications and background location.'** + String get backgroundTripIosDescriptionCompact; + + /// No description provided for @favoriteWidgetRefreshHelperCompact. + /// + /// In en, this message translates to: + /// **'Refresh at least 15 minutes apart; the system may delay it.'** + String get favoriteWidgetRefreshHelperCompact; + + /// No description provided for @updateChannelDeveloperDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Self-built version; update checks are disabled.'** + String get updateChannelDeveloperDescriptionCompact; + + /// No description provided for @updateChannelNightlyDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Get new features earlier; may be less stable.'** + String get updateChannelNightlyDescriptionCompact; + + /// No description provided for @updateChannelReleaseDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Receive published release updates only.'** + String get updateChannelReleaseDescriptionCompact; + + /// No description provided for @updateCheckOffDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Skip startup checks; check manually instead.'** + String get updateCheckOffDescriptionCompact; + + /// No description provided for @updateCheckNotifyDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Check at startup and notify about updates.'** + String get updateCheckNotifyDescriptionCompact; + + /// No description provided for @updateCheckPopupDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Check at startup and open the update dialog if available.'** + String get updateCheckPopupDescriptionCompact; + + /// No description provided for @personalizationBackgroundImageDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Set page backgrounds; Home applies to all transit home tabs.'** + String get personalizationBackgroundImageDescriptionCompact; + + /// No description provided for @personalizationColorSourceDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Auto uses background colors; System uses device colors.'** + String get personalizationColorSourceDescriptionCompact; + + /// No description provided for @personalizationGlobalHomeDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Apply to all transit home tabs.'** + String get personalizationGlobalHomeDescriptionCompact; + + /// No description provided for @adsLockedMessageCompact. + /// + /// In en, this message translates to: + /// **'Too many toggles; ads are locked on.'** + String get adsLockedMessageCompact; + + /// No description provided for @adsDisabledStatus. + /// + /// In en, this message translates to: + /// **'Ads are off.'** + String get adsDisabledStatus; + + /// No description provided for @personalizationAmoledDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Use a black background in dark mode.'** + String get personalizationAmoledDescriptionCompact; + + /// No description provided for @personalizationHomeGradientDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Adjust the Home gradient opacity.'** + String get personalizationHomeGradientDescriptionCompact; + + /// No description provided for @personalizationPerPageDescriptionCompact. + /// + /// In en, this message translates to: + /// **'Set a background for each page.'** + String get personalizationPerPageDescriptionCompact; + /// No description provided for @settingsTitle. /// /// In en, this message translates to: @@ -306,7 +552,7 @@ abstract class AppLocalizations { /// No description provided for @compactModeDescription. /// /// In en, this message translates to: - /// **'Reduce supporting descriptions on the home screen and some cards to keep common actions together. Turn it off for fuller explanations. The desktop home screen always uses a compact layout.'** + /// **'Turn off for full descriptions. Desktop Home stays compact.'** String get compactModeDescription; /// No description provided for @showWeatherTitle. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 75ac385..f455617 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -34,6 +34,167 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAboutDescription => 'Version information, contributors, community and feedback'; + @override + String get languageDescriptionCompact => + 'Change the app language immediately.'; + + @override + String get themeModeDescriptionCompact => 'Use light, dark or system theme.'; + + @override + String get mapProviderDescriptionCompact => + 'Change the base map, keeping the same bus data.'; + + @override + String get wearSyncCategoryDescriptionCompact => + 'Choose favorites groups to sync to your watch.'; + + @override + String get normalUpdateIntervalDescriptionCompact => + 'Arrival refresh interval; shorter uses more battery.'; + + @override + String get retryIntervalDescriptionCompact => + 'Retry after failures; waits at least the normal interval.'; + + @override + String get appUpdateManualCheckDescriptionCompact => + 'Check the selected channel for a new version.'; + + @override + String get searchHistoryLimitDescriptionCompact => + 'Keep recent routes; 0 clears and disables history.'; + + @override + String get clearSearchHistoryDescriptionCompact => + 'Clear recent routes; keep favorites and learning data.'; + + @override + String get clearSmartHistoryDescriptionCompact => + 'Reset recommendation learning; keep favorites.'; + + @override + String get clearRouteSelectionHistoryDescriptionCompact => + 'Clear route and stop selections; keep route open counts.'; + + @override + String get termsOfServiceDescriptionCompact => + 'Read the service rules and terms.'; + + @override + String get privacyPolicyDescriptionCompact => + 'Read how location, account and usage data are handled.'; + + @override + String get restartOnboardingDescriptionCompact => + 'Repeat first-time setup; keep favorites and preferences.'; + + @override + String get interfaceScaleDescriptionCompact => + 'Adjust text and interface size.'; + + @override + String get showWeatherDescriptionCompact => + 'Show local weather on Home; tap for the forecast.'; + + @override + String get personalizationDescriptionCompact => + 'Customize colors, AMOLED black mode and backgrounds.'; + + @override + String get wearSyncDescriptionCompact => + 'Sync favorites to your connected watch.'; + + @override + String get alwaysShowSecondsDescriptionCompact => + 'Show seconds when available; arrivals are estimates.'; + + @override + String get hapticFeedbackDescriptionCompact => + 'Brief vibrations for supported actions.'; + + @override + String get smartRecommendationsDescriptionCompact => + 'Suggest routes based on your habits and usual times.'; + + @override + String get autoFavoriteDescriptionCompact => + 'Automatically add frequent stops to Frequent favorites.'; + + @override + String get smartNotificationDescriptionCompact => + 'Arrival alerts at usual times; needs notifications and background location.'; + + @override + String get keepScreenAwakeDescriptionCompact => + 'Keep the screen on while viewing a route.'; + + @override + String get backgroundTripDescriptionCompact => + 'Trip alerts while locked; needs notifications and location.'; + + @override + String get backgroundTripIosDescriptionCompact => + 'Trip status while locked; needs notifications and background location.'; + + @override + String get favoriteWidgetRefreshHelperCompact => + 'Refresh at least 15 minutes apart; the system may delay it.'; + + @override + String get updateChannelDeveloperDescriptionCompact => + 'Self-built version; update checks are disabled.'; + + @override + String get updateChannelNightlyDescriptionCompact => + 'Get new features earlier; may be less stable.'; + + @override + String get updateChannelReleaseDescriptionCompact => + 'Receive published release updates only.'; + + @override + String get updateCheckOffDescriptionCompact => + 'Skip startup checks; check manually instead.'; + + @override + String get updateCheckNotifyDescriptionCompact => + 'Check at startup and notify about updates.'; + + @override + String get updateCheckPopupDescriptionCompact => + 'Check at startup and open the update dialog if available.'; + + @override + String get personalizationBackgroundImageDescriptionCompact => + 'Set page backgrounds; Home applies to all transit home tabs.'; + + @override + String get personalizationColorSourceDescriptionCompact => + 'Auto uses background colors; System uses device colors.'; + + @override + String get personalizationGlobalHomeDescriptionCompact => + 'Apply to all transit home tabs.'; + + @override + String get adsLockedMessageCompact => 'Too many toggles; ads are locked on.'; + + @override + String get adsDisabledStatus => 'Ads are off.'; + + @override + String get personalizationAmoledDescriptionCompact => + 'Use a black background in dark mode.'; + + @override + String get personalizationHomeGradientDescriptionCompact => + 'Adjust the Home gradient opacity.'; + + @override + String get personalizationPerPageDescriptionCompact => + 'Set a background for each page.'; + @override String get settingsTitle => 'Settings'; @@ -134,7 +295,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get compactModeDescription => - 'Reduce supporting descriptions on the home screen and some cards to keep common actions together. Turn it off for fuller explanations. The desktop home screen always uses a compact layout.'; + 'Turn off for full descriptions. Desktop Home stays compact.'; @override String get showWeatherTitle => 'Show weather'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0e7a7a1..562507e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -29,6 +29,130 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsAboutDescription => '版本資訊、貢獻者、社群與意見回饋'; + @override + String get languageDescriptionCompact => '切換介面語言,立即生效。'; + + @override + String get themeModeDescriptionCompact => '選擇淺色、深色或跟隨系統。'; + + @override + String get mapProviderDescriptionCompact => '切換底圖,不影響公車資料來源。'; + + @override + String get wearSyncCategoryDescriptionCompact => '選擇同步至手錶的最愛分類。'; + + @override + String get normalUpdateIntervalDescriptionCompact => '到站資訊更新間隔;越短越耗電。'; + + @override + String get retryIntervalDescriptionCompact => '失敗後重試;實際不短於一般更新間隔。'; + + @override + String get appUpdateManualCheckDescriptionCompact => '檢查目前更新通道是否有新版。'; + + @override + String get searchHistoryLimitDescriptionCompact => '保留最近路線;0 為清空並停止記錄。'; + + @override + String get clearSearchHistoryDescriptionCompact => '清除最近路線,保留最愛與學習紀錄。'; + + @override + String get clearSmartHistoryDescriptionCompact => '重設推薦學習紀錄,保留最愛。'; + + @override + String get clearRouteSelectionHistoryDescriptionCompact => '清除路線選擇紀錄,保留開啟次數。'; + + @override + String get termsOfServiceDescriptionCompact => '查看服務規則與使用條款。'; + + @override + String get privacyPolicyDescriptionCompact => '查看定位、帳號與使用資料的處理方式。'; + + @override + String get restartOnboardingDescriptionCompact => '重跑初次設定,保留最愛與偏好。'; + + @override + String get interfaceScaleDescriptionCompact => '調整文字與介面大小。'; + + @override + String get showWeatherDescriptionCompact => '首頁顯示當地天氣;點按看預報。'; + + @override + String get personalizationDescriptionCompact => '調整配色、純黑模式與背景。'; + + @override + String get wearSyncDescriptionCompact => '將最愛同步至已連線的手錶。'; + + @override + String get alwaysShowSecondsDescriptionCompact => '有秒數資料時顯示倒數,仍為預估。'; + + @override + String get hapticFeedbackDescriptionCompact => '操作時提供短暫震動。'; + + @override + String get smartRecommendationsDescriptionCompact => '依常用路線與時段推薦搭車選項。'; + + @override + String get autoFavoriteDescriptionCompact => '常搭站牌自動加入「常用」最愛。'; + + @override + String get smartNotificationDescriptionCompact => '常用時段發送到站提醒,需通知與背景定位。'; + + @override + String get keepScreenAwakeDescriptionCompact => '查看路線時保持螢幕亮起。'; + + @override + String get backgroundTripDescriptionCompact => '鎖定螢幕後仍有乘車提醒,需通知與定位權限。'; + + @override + String get backgroundTripIosDescriptionCompact => '鎖定螢幕後仍顯示乘車狀態,需通知與背景定位。'; + + @override + String get favoriteWidgetRefreshHelperCompact => '最短 15 分鐘更新一次;系統可能延後。'; + + @override + String get updateChannelDeveloperDescriptionCompact => '自行建置版本,不檢查更新。'; + + @override + String get updateChannelNightlyDescriptionCompact => '提早取得新功能,可能較不穩定。'; + + @override + String get updateChannelReleaseDescriptionCompact => '僅接收正式發行版更新。'; + + @override + String get updateCheckOffDescriptionCompact => '啟動時不檢查,可手動檢查。'; + + @override + String get updateCheckNotifyDescriptionCompact => '啟動時檢查,有新版時通知。'; + + @override + String get updateCheckPopupDescriptionCompact => '啟動時檢查,有新版時開啟更新視窗。'; + + @override + String get personalizationBackgroundImageDescriptionCompact => + '設定各頁背景;首頁圖片套用至所有交通首頁。'; + + @override + String get personalizationColorSourceDescriptionCompact => '自動取背景色;系統使用裝置配色。'; + + @override + String get personalizationGlobalHomeDescriptionCompact => '套用至所有交通首頁分頁。'; + + @override + String get adsLockedMessageCompact => '切換過於頻繁,已鎖定為開啟。'; + + @override + String get adsDisabledStatus => '已關閉廣告。'; + + @override + String get personalizationAmoledDescriptionCompact => '深色模式使用純黑背景。'; + + @override + String get personalizationHomeGradientDescriptionCompact => '調整首頁漸層透明度。'; + + @override + String get personalizationPerPageDescriptionCompact => '分別設定各頁背景。'; + @override String get settingsTitle => '設定'; @@ -127,8 +251,7 @@ class AppLocalizationsZh extends AppLocalizations { String get compactModeTitle => '精簡模式'; @override - String get compactModeDescription => - '減少首頁與部分卡片的輔助說明,讓常用功能更集中,適合熟悉操作後使用。關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。'; + String get compactModeDescription => '關閉後顯示完整說明;桌面首頁固定精簡。'; @override String get showWeatherTitle => '顯示天氣'; @@ -3361,6 +3484,130 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get settingsAboutDescription => '版本資訊、貢獻者、社群與意見回饋'; + @override + String get languageDescriptionCompact => '切換介面語言,立即生效。'; + + @override + String get themeModeDescriptionCompact => '選擇淺色、深色或跟隨系統。'; + + @override + String get mapProviderDescriptionCompact => '切換底圖,不影響公車資料來源。'; + + @override + String get wearSyncCategoryDescriptionCompact => '選擇同步至手錶的最愛分類。'; + + @override + String get normalUpdateIntervalDescriptionCompact => '到站資訊更新間隔;越短越耗電。'; + + @override + String get retryIntervalDescriptionCompact => '失敗後重試;實際不短於一般更新間隔。'; + + @override + String get appUpdateManualCheckDescriptionCompact => '檢查目前更新通道是否有新版。'; + + @override + String get searchHistoryLimitDescriptionCompact => '保留最近路線;0 為清空並停止記錄。'; + + @override + String get clearSearchHistoryDescriptionCompact => '清除最近路線,保留最愛與學習紀錄。'; + + @override + String get clearSmartHistoryDescriptionCompact => '重設推薦學習紀錄,保留最愛。'; + + @override + String get clearRouteSelectionHistoryDescriptionCompact => '清除路線選擇紀錄,保留開啟次數。'; + + @override + String get termsOfServiceDescriptionCompact => '查看服務規則與使用條款。'; + + @override + String get privacyPolicyDescriptionCompact => '查看定位、帳號與使用資料的處理方式。'; + + @override + String get restartOnboardingDescriptionCompact => '重跑初次設定,保留最愛與偏好。'; + + @override + String get interfaceScaleDescriptionCompact => '調整文字與介面大小。'; + + @override + String get showWeatherDescriptionCompact => '首頁顯示當地天氣;點按看預報。'; + + @override + String get personalizationDescriptionCompact => '調整配色、純黑模式與背景。'; + + @override + String get wearSyncDescriptionCompact => '將最愛同步至已連線的手錶。'; + + @override + String get alwaysShowSecondsDescriptionCompact => '有秒數資料時顯示倒數,仍為預估。'; + + @override + String get hapticFeedbackDescriptionCompact => '操作時提供短暫震動。'; + + @override + String get smartRecommendationsDescriptionCompact => '依常用路線與時段推薦搭車選項。'; + + @override + String get autoFavoriteDescriptionCompact => '常搭站牌自動加入「常用」最愛。'; + + @override + String get smartNotificationDescriptionCompact => '常用時段發送到站提醒,需通知與背景定位。'; + + @override + String get keepScreenAwakeDescriptionCompact => '查看路線時保持螢幕亮起。'; + + @override + String get backgroundTripDescriptionCompact => '鎖定螢幕後仍有乘車提醒,需通知與定位權限。'; + + @override + String get backgroundTripIosDescriptionCompact => '鎖定螢幕後仍顯示乘車狀態,需通知與背景定位。'; + + @override + String get favoriteWidgetRefreshHelperCompact => '最短 15 分鐘更新一次;系統可能延後。'; + + @override + String get updateChannelDeveloperDescriptionCompact => '自行建置版本,不檢查更新。'; + + @override + String get updateChannelNightlyDescriptionCompact => '提早取得新功能,可能較不穩定。'; + + @override + String get updateChannelReleaseDescriptionCompact => '僅接收正式發行版更新。'; + + @override + String get updateCheckOffDescriptionCompact => '啟動時不檢查,可手動檢查。'; + + @override + String get updateCheckNotifyDescriptionCompact => '啟動時檢查,有新版時通知。'; + + @override + String get updateCheckPopupDescriptionCompact => '啟動時檢查,有新版時開啟更新視窗。'; + + @override + String get personalizationBackgroundImageDescriptionCompact => + '設定各頁背景;首頁圖片套用至所有交通首頁。'; + + @override + String get personalizationColorSourceDescriptionCompact => '自動取背景色;系統使用裝置配色。'; + + @override + String get personalizationGlobalHomeDescriptionCompact => '套用至所有交通首頁分頁。'; + + @override + String get adsLockedMessageCompact => '切換過於頻繁,已鎖定為開啟。'; + + @override + String get adsDisabledStatus => '已關閉廣告。'; + + @override + String get personalizationAmoledDescriptionCompact => '深色模式使用純黑背景。'; + + @override + String get personalizationHomeGradientDescriptionCompact => '調整首頁漸層透明度。'; + + @override + String get personalizationPerPageDescriptionCompact => '分別設定各頁背景。'; + @override String get settingsTitle => '設定'; @@ -3459,8 +3706,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get compactModeTitle => '精簡模式'; @override - String get compactModeDescription => - '減少首頁與部分卡片的輔助說明,讓常用功能更集中,適合熟悉操作後使用。關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。'; + String get compactModeDescription => '關閉後顯示完整說明;桌面首頁固定精簡。'; @override String get showWeatherTitle => '顯示天氣'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c91e015..329b1dc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -7,6 +7,47 @@ "settingsHistoryDescription": "搜尋紀錄、資料清除與隱私政策", "settingsOnboardingDescription": "重新進行初次使用設定", "settingsAboutDescription": "版本資訊、貢獻者、社群與意見回饋", + "languageDescriptionCompact": "切換介面語言,立即生效。", + "themeModeDescriptionCompact": "選擇淺色、深色或跟隨系統。", + "mapProviderDescriptionCompact": "切換底圖,不影響公車資料來源。", + "wearSyncCategoryDescriptionCompact": "選擇同步至手錶的最愛分類。", + "normalUpdateIntervalDescriptionCompact": "到站資訊更新間隔;越短越耗電。", + "retryIntervalDescriptionCompact": "失敗後重試;實際不短於一般更新間隔。", + "appUpdateManualCheckDescriptionCompact": "檢查目前更新通道是否有新版。", + "searchHistoryLimitDescriptionCompact": "保留最近路線;0 為清空並停止記錄。", + "clearSearchHistoryDescriptionCompact": "清除最近路線,保留最愛與學習紀錄。", + "clearSmartHistoryDescriptionCompact": "重設推薦學習紀錄,保留最愛。", + "clearRouteSelectionHistoryDescriptionCompact": "清除路線選擇紀錄,保留開啟次數。", + "termsOfServiceDescriptionCompact": "查看服務規則與使用條款。", + "privacyPolicyDescriptionCompact": "查看定位、帳號與使用資料的處理方式。", + "restartOnboardingDescriptionCompact": "重跑初次設定,保留最愛與偏好。", + "interfaceScaleDescriptionCompact": "調整文字與介面大小。", + "showWeatherDescriptionCompact": "首頁顯示當地天氣;點按看預報。", + "personalizationDescriptionCompact": "調整配色、純黑模式與背景。", + "wearSyncDescriptionCompact": "將最愛同步至已連線的手錶。", + "alwaysShowSecondsDescriptionCompact": "有秒數資料時顯示倒數,仍為預估。", + "hapticFeedbackDescriptionCompact": "操作時提供短暫震動。", + "smartRecommendationsDescriptionCompact": "依常用路線與時段推薦搭車選項。", + "autoFavoriteDescriptionCompact": "常搭站牌自動加入「常用」最愛。", + "smartNotificationDescriptionCompact": "常用時段發送到站提醒,需通知與背景定位。", + "keepScreenAwakeDescriptionCompact": "查看路線時保持螢幕亮起。", + "backgroundTripDescriptionCompact": "鎖定螢幕後仍有乘車提醒,需通知與定位權限。", + "backgroundTripIosDescriptionCompact": "鎖定螢幕後仍顯示乘車狀態,需通知與背景定位。", + "favoriteWidgetRefreshHelperCompact": "最短 15 分鐘更新一次;系統可能延後。", + "updateChannelDeveloperDescriptionCompact": "自行建置版本,不檢查更新。", + "updateChannelNightlyDescriptionCompact": "提早取得新功能,可能較不穩定。", + "updateChannelReleaseDescriptionCompact": "僅接收正式發行版更新。", + "updateCheckOffDescriptionCompact": "啟動時不檢查,可手動檢查。", + "updateCheckNotifyDescriptionCompact": "啟動時檢查,有新版時通知。", + "updateCheckPopupDescriptionCompact": "啟動時檢查,有新版時開啟更新視窗。", + "personalizationBackgroundImageDescriptionCompact": "設定各頁背景;首頁圖片套用至所有交通首頁。", + "personalizationColorSourceDescriptionCompact": "自動取背景色;系統使用裝置配色。", + "personalizationGlobalHomeDescriptionCompact": "套用至所有交通首頁分頁。", + "adsLockedMessageCompact": "切換過於頻繁,已鎖定為開啟。", + "adsDisabledStatus": "已關閉廣告。", + "personalizationAmoledDescriptionCompact": "深色模式使用純黑背景。", + "personalizationHomeGradientDescriptionCompact": "調整首頁漸層透明度。", + "personalizationPerPageDescriptionCompact": "分別設定各頁背景。", "settingsTitle": "設定", "appearanceSectionTitle": "外觀", "languageLabel": "語言", @@ -41,7 +82,7 @@ "themeModeLight": "淺色", "themeModeDark": "深色", "compactModeTitle": "精簡模式", - "compactModeDescription": "減少首頁與部分卡片的輔助說明,讓常用功能更集中,適合熟悉操作後使用。關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。", + "compactModeDescription": "關閉後顯示完整說明;桌面首頁固定精簡。", "showWeatherTitle": "顯示天氣", "showWeatherDescription": "在首頁標題旁顯示目前位置的氣溫與天氣,點一下可查看完整預報。需要可用的位置與網路資訊;關閉後首頁標題就不會顯示天氣。", "mapProviderLabel": "地圖提供者", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f4bc121..efc51f5 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -7,6 +7,47 @@ "settingsHistoryDescription": "搜尋紀錄、資料清除與隱私政策", "settingsOnboardingDescription": "重新進行初次使用設定", "settingsAboutDescription": "版本資訊、貢獻者、社群與意見回饋", + "languageDescriptionCompact": "切換介面語言,立即生效。", + "themeModeDescriptionCompact": "選擇淺色、深色或跟隨系統。", + "mapProviderDescriptionCompact": "切換底圖,不影響公車資料來源。", + "wearSyncCategoryDescriptionCompact": "選擇同步至手錶的最愛分類。", + "normalUpdateIntervalDescriptionCompact": "到站資訊更新間隔;越短越耗電。", + "retryIntervalDescriptionCompact": "失敗後重試;實際不短於一般更新間隔。", + "appUpdateManualCheckDescriptionCompact": "檢查目前更新通道是否有新版。", + "searchHistoryLimitDescriptionCompact": "保留最近路線;0 為清空並停止記錄。", + "clearSearchHistoryDescriptionCompact": "清除最近路線,保留最愛與學習紀錄。", + "clearSmartHistoryDescriptionCompact": "重設推薦學習紀錄,保留最愛。", + "clearRouteSelectionHistoryDescriptionCompact": "清除路線選擇紀錄,保留開啟次數。", + "termsOfServiceDescriptionCompact": "查看服務規則與使用條款。", + "privacyPolicyDescriptionCompact": "查看定位、帳號與使用資料的處理方式。", + "restartOnboardingDescriptionCompact": "重跑初次設定,保留最愛與偏好。", + "interfaceScaleDescriptionCompact": "調整文字與介面大小。", + "showWeatherDescriptionCompact": "首頁顯示當地天氣;點按看預報。", + "personalizationDescriptionCompact": "調整配色、純黑模式與背景。", + "wearSyncDescriptionCompact": "將最愛同步至已連線的手錶。", + "alwaysShowSecondsDescriptionCompact": "有秒數資料時顯示倒數,仍為預估。", + "hapticFeedbackDescriptionCompact": "操作時提供短暫震動。", + "smartRecommendationsDescriptionCompact": "依常用路線與時段推薦搭車選項。", + "autoFavoriteDescriptionCompact": "常搭站牌自動加入「常用」最愛。", + "smartNotificationDescriptionCompact": "常用時段發送到站提醒,需通知與背景定位。", + "keepScreenAwakeDescriptionCompact": "查看路線時保持螢幕亮起。", + "backgroundTripDescriptionCompact": "鎖定螢幕後仍有乘車提醒,需通知與定位權限。", + "backgroundTripIosDescriptionCompact": "鎖定螢幕後仍顯示乘車狀態,需通知與背景定位。", + "favoriteWidgetRefreshHelperCompact": "最短 15 分鐘更新一次;系統可能延後。", + "updateChannelDeveloperDescriptionCompact": "自行建置版本,不檢查更新。", + "updateChannelNightlyDescriptionCompact": "提早取得新功能,可能較不穩定。", + "updateChannelReleaseDescriptionCompact": "僅接收正式發行版更新。", + "updateCheckOffDescriptionCompact": "啟動時不檢查,可手動檢查。", + "updateCheckNotifyDescriptionCompact": "啟動時檢查,有新版時通知。", + "updateCheckPopupDescriptionCompact": "啟動時檢查,有新版時開啟更新視窗。", + "personalizationBackgroundImageDescriptionCompact": "設定各頁背景;首頁圖片套用至所有交通首頁。", + "personalizationColorSourceDescriptionCompact": "自動取背景色;系統使用裝置配色。", + "personalizationGlobalHomeDescriptionCompact": "套用至所有交通首頁分頁。", + "adsLockedMessageCompact": "切換過於頻繁,已鎖定為開啟。", + "adsDisabledStatus": "已關閉廣告。", + "personalizationAmoledDescriptionCompact": "深色模式使用純黑背景。", + "personalizationHomeGradientDescriptionCompact": "調整首頁漸層透明度。", + "personalizationPerPageDescriptionCompact": "分別設定各頁背景。", "settingsTitle": "設定", "appearanceSectionTitle": "外觀", "languageLabel": "語言", @@ -41,7 +82,7 @@ "themeModeLight": "淺色", "themeModeDark": "深色", "compactModeTitle": "精簡模式", - "compactModeDescription": "減少首頁與部分卡片的輔助說明,讓常用功能更集中,適合熟悉操作後使用。關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。", + "compactModeDescription": "關閉後顯示完整說明;桌面首頁固定精簡。", "showWeatherTitle": "顯示天氣", "showWeatherDescription": "在首頁標題旁顯示目前位置的氣溫與天氣,點一下可查看完整預報。需要可用的位置與網路資訊;關閉後首頁標題就不會顯示天氣。", "mapProviderLabel": "地圖提供者", diff --git a/lib/l10n/localized_labels.dart b/lib/l10n/localized_labels.dart index 491e71c..964dc27 100644 --- a/lib/l10n/localized_labels.dart +++ b/lib/l10n/localized_labels.dart @@ -143,11 +143,21 @@ String localizedAppUpdateChannel( String localizedAppUpdateChannelDescription( AppLocalizations l10n, - AppUpdateChannel channel, -) => switch (channel) { - AppUpdateChannel.developer => l10n.updateChannelDeveloperDescription, - AppUpdateChannel.nightly => l10n.updateChannelNightlyDescription, - AppUpdateChannel.release => l10n.updateChannelReleaseDescription, + AppUpdateChannel channel, { + bool compact = false, +}) => switch (channel) { + AppUpdateChannel.developer => + compact + ? l10n.updateChannelDeveloperDescriptionCompact + : l10n.updateChannelDeveloperDescription, + AppUpdateChannel.nightly => + compact + ? l10n.updateChannelNightlyDescriptionCompact + : l10n.updateChannelNightlyDescription, + AppUpdateChannel.release => + compact + ? l10n.updateChannelReleaseDescriptionCompact + : l10n.updateChannelReleaseDescription, }; String localizedAppUpdateCheckMode( @@ -161,11 +171,21 @@ String localizedAppUpdateCheckMode( String localizedAppUpdateCheckModeDescription( AppLocalizations l10n, - AppUpdateCheckMode mode, -) => switch (mode) { - AppUpdateCheckMode.off => l10n.updateCheckOffDescription, - AppUpdateCheckMode.notify => l10n.updateCheckNotifyDescription, - AppUpdateCheckMode.popup => l10n.updateCheckPopupDescription, + AppUpdateCheckMode mode, { + bool compact = false, +}) => switch (mode) { + AppUpdateCheckMode.off => + compact + ? l10n.updateCheckOffDescriptionCompact + : l10n.updateCheckOffDescription, + AppUpdateCheckMode.notify => + compact + ? l10n.updateCheckNotifyDescriptionCompact + : l10n.updateCheckNotifyDescription, + AppUpdateCheckMode.popup => + compact + ? l10n.updateCheckPopupDescriptionCompact + : l10n.updateCheckPopupDescription, }; String localizedAppUpdateResult( diff --git a/lib/screens/personalization_screen.dart b/lib/screens/personalization_screen.dart index 74d35a1..c62a08d 100644 --- a/lib/screens/personalization_screen.dart +++ b/lib/screens/personalization_screen.dart @@ -195,7 +195,11 @@ class PersonalizationScreen extends StatelessWidget { contentPadding: EdgeInsets.zero, secondary: const Icon(Icons.dark_mode_outlined), title: Text(l10n.personalizationAmoledTitle), - subtitle: Text(l10n.personalizationAmoledDescription), + subtitle: Text( + settings.enableCompactMode + ? l10n.personalizationAmoledDescriptionCompact + : l10n.personalizationAmoledDescription, + ), value: isAmoled, onChanged: !isDark ? null @@ -222,7 +226,9 @@ class PersonalizationScreen extends StatelessWidget { ), const SizedBox(height: 4), Text( - l10n.personalizationHomeGradientDescription, + settings.enableCompactMode + ? l10n.personalizationHomeGradientDescriptionCompact + : l10n.personalizationHomeGradientDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), @@ -254,7 +260,9 @@ class PersonalizationScreen extends StatelessWidget { ), const SizedBox(height: 4), Text( - l10n.personalizationBackgroundImageDescription, + settings.enableCompactMode + ? l10n.personalizationBackgroundImageDescriptionCompact + : l10n.personalizationBackgroundImageDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), @@ -332,7 +340,9 @@ class PersonalizationScreen extends StatelessWidget { l10n.personalizationPerPageSettings, ), subtitle: Text( - l10n.personalizationPerPageDescription, + settings.enableCompactMode + ? l10n.personalizationPerPageDescriptionCompact + : l10n.personalizationPerPageDescription, ), trailing: const Icon(Icons.chevron_right), onTap: () { @@ -446,9 +456,13 @@ class PersonalizationScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of( - context, - ).personalizationColorSourceDescription, + settings.enableCompactMode + ? AppLocalizations.of( + context, + ).personalizationColorSourceDescriptionCompact + : AppLocalizations.of( + context, + ).personalizationColorSourceDescription, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 12), @@ -779,7 +793,9 @@ class _PerPageBackgroundScreen extends StatelessWidget { pageKey: pageKey, label: _localizedPageLabel(l10n, pageKey), subtitle: pageKey == 'bus' - ? l10n.personalizationGlobalHomeDescription + ? settings.enableCompactMode + ? l10n.personalizationGlobalHomeDescriptionCompact + : l10n.personalizationGlobalHomeDescription : null, icon: _pageIcons[pageKey]!, imagePath: settings.pageBackgroundImagePaths[pageKey], diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 381eb36..b4ca4e6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -62,6 +62,9 @@ class _SettingsScreenState extends State { bool _adToggleLocked = false; bool _adToggleConfirmed = false; + bool get _compactMode => + AppControllerScope.of(context).settings.enableCompactMode; + @override void initState() { super.initState(); @@ -315,12 +318,16 @@ class _SettingsScreenState extends State { required String title, required String description, required VoidCallback onTap, + String? compactDescription, }) { final colors = Theme.of(context).colorScheme; return Card( clipBehavior: Clip.antiAlias, child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: _compactMode ? 0 : 8, + ), leading: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( @@ -330,7 +337,10 @@ class _SettingsScreenState extends State { child: Icon(icon, color: colors.onPrimaryContainer), ), title: Text(title), - subtitle: Text(description), + subtitle: _buildSettingSubtitle( + description, + compactDescription: compactDescription, + ), trailing: const Icon(Icons.chevron_right), onTap: onTap, ), @@ -379,6 +389,7 @@ class _SettingsScreenState extends State { icon: Icons.watch_outlined, title: 'Wear OS', description: l10n.wearSyncDescription, + compactDescription: l10n.wearSyncDescriptionCompact, onTap: () => _openCategory(_SettingsCategory.wearOs), ); }, @@ -452,13 +463,24 @@ class _SettingsScreenState extends State { _SettingsCategory.about => _buildAbout(context), }; - Widget _buildSettingDescription(String description) => Padding( - padding: const EdgeInsets.only(top: 8, bottom: 12), + Widget _buildSettingSubtitle( + String description, { + String? compactDescription, + }) => Text(_compactMode ? compactDescription ?? description : description); + + Widget _buildSettingDescription( + String description, { + String? compactDescription, + }) => Padding( + padding: EdgeInsets.only( + top: _compactMode ? 4 : 8, + bottom: _compactMode ? 6 : 12, + ), child: Text( - description, + _compactMode ? compactDescription ?? description : description, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, - height: 1.6, + height: _compactMode ? 1.3 : 1.6, ), ), ); @@ -467,6 +489,7 @@ class _SettingsScreenState extends State { required IconData icon, required String label, required String description, + required String compactDescription, required VoidCallback onPressed, }) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -476,7 +499,10 @@ class _SettingsScreenState extends State { icon: Icon(icon), label: Text(label), ), - _buildSettingDescription(description), + _buildSettingDescription( + description, + compactDescription: compactDescription, + ), ], ); @@ -531,7 +557,7 @@ class _SettingsScreenState extends State { else ListTileTheme( data: ListTileThemeData( - minVerticalPadding: 12, + minVerticalPadding: _compactMode ? 4 : 12, subtitleTextStyle: Theme.of(context) .textTheme .bodyMedium @@ -539,7 +565,7 @@ class _SettingsScreenState extends State { color: Theme.of( context, ).colorScheme.onSurfaceVariant, - height: 1.6, + height: _compactMode ? 1.3 : 1.6, ), ), child: _buildCategory(context, category), @@ -600,7 +626,10 @@ class _SettingsScreenState extends State { ], onChanged: controller.updateLanguage, ), - _buildSettingDescription(l10n.languageDescription), + _buildSettingDescription( + l10n.languageDescription, + compactDescription: l10n.languageDescriptionCompact, + ), const SizedBox(height: 16), Text( '${l10n.interfaceScaleLabel}: ' @@ -609,8 +638,12 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 4), Text( - l10n.interfaceScaleDescription, - style: theme.textTheme.bodyMedium?.copyWith(height: 1.6), + _compactMode + ? l10n.interfaceScaleDescriptionCompact + : l10n.interfaceScaleDescription, + style: theme.textTheme.bodyMedium?.copyWith( + height: _compactMode ? 1.3 : 1.6, + ), ), Slider( min: AppSettings.minInterfaceScale, @@ -649,19 +682,25 @@ class _SettingsScreenState extends State { } }, ), - _buildSettingDescription(l10n.themeModeDescription), + _buildSettingDescription( + l10n.themeModeDescription, + compactDescription: l10n.themeModeDescriptionCompact, + ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.compactModeTitle), - subtitle: Text(l10n.compactModeDescription), + subtitle: _buildSettingSubtitle(l10n.compactModeDescription), value: controller.settings.enableCompactMode, onChanged: controller.updateEnableCompactMode, ), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.showWeatherTitle), - subtitle: Text(l10n.showWeatherDescription), + subtitle: _buildSettingSubtitle( + l10n.showWeatherDescription, + compactDescription: l10n.showWeatherDescriptionCompact, + ), value: controller.settings.showWeatherInAppBar, onChanged: controller.updateShowWeatherInAppBar, ), @@ -685,14 +724,20 @@ class _SettingsScreenState extends State { } }, ), - _buildSettingDescription(l10n.mapProviderDescription), + _buildSettingDescription( + l10n.mapProviderDescription, + compactDescription: l10n.mapProviderDescriptionCompact, + ), ], const SizedBox(height: 12), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.palette_outlined), title: Text(l10n.personalizationTitle), - subtitle: Text(l10n.personalizationDescription), + subtitle: _buildSettingSubtitle( + l10n.personalizationDescription, + compactDescription: l10n.personalizationDescriptionCompact, + ), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -776,7 +821,10 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.wearSyncTitle), - subtitle: Text(l10n.wearSyncDescription), + subtitle: _buildSettingSubtitle( + l10n.wearSyncDescription, + compactDescription: l10n.wearSyncDescriptionCompact, + ), value: controller.settings.wearSyncEnabled, onChanged: controller.updateWearSyncEnabled, ), @@ -824,7 +872,11 @@ class _SettingsScreenState extends State { } }, ), - _buildSettingDescription(l10n.wearSyncCategoryDescription), + _buildSettingDescription( + l10n.wearSyncCategoryDescription, + compactDescription: + l10n.wearSyncCategoryDescriptionCompact, + ), ], ], ], @@ -851,7 +903,10 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.alwaysShowSecondsTitle), - subtitle: Text(l10n.alwaysShowSecondsDescription), + subtitle: _buildSettingSubtitle( + l10n.alwaysShowSecondsDescription, + compactDescription: l10n.alwaysShowSecondsDescriptionCompact, + ), value: controller.settings.alwaysShowSeconds, onChanged: controller.updateAlwaysShowSeconds, ), @@ -859,7 +914,10 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.hapticFeedbackTitle), - subtitle: Text(l10n.hapticFeedbackDescription), + subtitle: _buildSettingSubtitle( + l10n.hapticFeedbackDescription, + compactDescription: l10n.hapticFeedbackDescriptionCompact, + ), value: controller.settings.enableHapticFeedback, onChanged: controller.updateEnableHapticFeedback, ), @@ -879,6 +937,10 @@ class _SettingsScreenState extends State { height: 20, ), ), + if (_compactMode) + TextSpan( + text: ' ${l10n.adsLockedMessageCompact}', + ), TextSpan(text: l10n.adsLockedMessage), ], ), @@ -899,6 +961,8 @@ class _SettingsScreenState extends State { height: 20, ), ), + if (_compactMode) + TextSpan(text: ' ${l10n.adsDisabledStatus}'), TextSpan( text: ' ${[l10n.adsPleaOne, l10n.adsPleaTwo, l10n.adsPleaThree, l10n.adsPleaFour][Random().nextInt(4)]}', @@ -919,14 +983,20 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.smartRecommendationsTitle), - subtitle: Text(l10n.smartRecommendationsDescription), + subtitle: _buildSettingSubtitle( + l10n.smartRecommendationsDescription, + compactDescription: l10n.smartRecommendationsDescriptionCompact, + ), value: controller.settings.enableSmartRecommendations, onChanged: controller.updateEnableSmartRecommendations, ), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.autoFavoriteTitle), - subtitle: Text(l10n.autoFavoriteDescription), + subtitle: _buildSettingSubtitle( + l10n.autoFavoriteDescription, + compactDescription: l10n.autoFavoriteDescriptionCompact, + ), value: controller.settings.enableAutoFavoriteFrequentStops, onChanged: controller.updateEnableAutoFavoriteFrequentStops, ), @@ -934,7 +1004,10 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.smartNotificationTitle), - subtitle: Text(l10n.smartNotificationDescription), + subtitle: _buildSettingSubtitle( + l10n.smartNotificationDescription, + compactDescription: l10n.smartNotificationDescriptionCompact, + ), value: controller.settings.enableSmartRouteNotifications, onChanged: (value) => _toggleSmartRouteNotifications(context, controller, value), @@ -942,7 +1015,10 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.keepScreenAwakeTitle), - subtitle: Text(l10n.keepScreenAwakeDescription), + subtitle: _buildSettingSubtitle( + l10n.keepScreenAwakeDescription, + compactDescription: l10n.keepScreenAwakeDescriptionCompact, + ), value: controller.settings.keepScreenAwakeOnRouteDetail, onChanged: controller.updateKeepScreenAwakeOnRouteDetail, ), @@ -950,10 +1026,13 @@ class _SettingsScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.backgroundTripTitle), - subtitle: Text( + subtitle: _buildSettingSubtitle( isIOS ? l10n.backgroundTripIosDescription : l10n.backgroundTripDescription, + compactDescription: isIOS + ? l10n.backgroundTripIosDescriptionCompact + : l10n.backgroundTripDescriptionCompact, ), value: controller.settings.enableRouteBackgroundMonitor, onChanged: (value) { @@ -983,11 +1062,17 @@ class _SettingsScreenState extends State { } }, ), - _buildSettingDescription(l10n.favoriteWidgetRefreshHelper), + _buildSettingDescription( + l10n.favoriteWidgetRefreshHelper, + compactDescription: l10n.favoriteWidgetRefreshHelperCompact, + ), ], const SizedBox(height: 8), Text(l10n.normalUpdateInterval(controller.settings.busUpdateTime)), - _buildSettingDescription(l10n.normalUpdateIntervalDescription), + _buildSettingDescription( + l10n.normalUpdateIntervalDescription, + compactDescription: l10n.normalUpdateIntervalDescriptionCompact, + ), Slider( min: 5, max: 60, @@ -1000,7 +1085,10 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text(l10n.retryInterval(controller.settings.busErrorUpdateTime)), - _buildSettingDescription(l10n.retryIntervalDescription), + _buildSettingDescription( + l10n.retryIntervalDescription, + compactDescription: l10n.retryIntervalDescriptionCompact, + ), Slider( min: 1, max: 15, @@ -1048,6 +1136,7 @@ class _SettingsScreenState extends State { localizedAppUpdateChannelDescription( l10n, controller.settings.appUpdateChannel, + compact: _compactMode, ), ), const SizedBox(height: 12), @@ -1075,10 +1164,14 @@ class _SettingsScreenState extends State { localizedAppUpdateCheckModeDescription( l10n, controller.settings.appUpdateCheckMode, + compact: _compactMode, ), ), const SizedBox(height: 12), - _buildSettingDescription(l10n.appUpdateManualCheckDescription), + _buildSettingDescription( + l10n.appUpdateManualCheckDescription, + compactDescription: l10n.appUpdateManualCheckDescriptionCompact, + ), FilledButton.icon( onPressed: controller.checkingAppUpdate ? null @@ -1120,7 +1213,10 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.searchHistoryLimit(controller.settings.maxHistory)), - _buildSettingDescription(l10n.searchHistoryLimitDescription), + _buildSettingDescription( + l10n.searchHistoryLimitDescription, + compactDescription: l10n.searchHistoryLimitDescriptionCompact, + ), Slider( min: 0, max: 30, @@ -1141,25 +1237,32 @@ class _SettingsScreenState extends State { icon: Icons.delete_outline_rounded, label: l10n.clearSearchHistory, description: l10n.clearSearchHistoryDescription, + compactDescription: l10n.clearSearchHistoryDescriptionCompact, ), _buildHistoryAction( onPressed: controller.clearRouteUsageProfiles, icon: Icons.psychology_alt_outlined, label: l10n.clearSmartHistory, description: l10n.clearSmartHistoryDescription, + compactDescription: l10n.clearSmartHistoryDescriptionCompact, ), _buildHistoryAction( onPressed: controller.clearRouteSelectionHistory, icon: Icons.route_outlined, label: l10n.clearRouteSelectionHistory, description: l10n.clearRouteSelectionHistoryDescription, + compactDescription: + l10n.clearRouteSelectionHistoryDescriptionCompact, ), const SizedBox(height: 16), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.gavel_outlined), title: Text(l10n.termsOfService), - subtitle: Text(l10n.termsOfServiceDescription), + subtitle: _buildSettingSubtitle( + l10n.termsOfServiceDescription, + compactDescription: l10n.termsOfServiceDescriptionCompact, + ), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).pushNamed(AppRoutes.termsOfService); @@ -1169,7 +1272,10 @@ class _SettingsScreenState extends State { contentPadding: EdgeInsets.zero, leading: const Icon(Icons.privacy_tip_outlined), title: Text(l10n.privacyPolicy), - subtitle: Text(l10n.privacyPolicyDescription), + subtitle: _buildSettingSubtitle( + l10n.privacyPolicyDescription, + compactDescription: l10n.privacyPolicyDescriptionCompact, + ), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).pushNamed(AppRoutes.privacyPolicy); @@ -1190,7 +1296,10 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSettingDescription(l10n.restartOnboardingDescription), + _buildSettingDescription( + l10n.restartOnboardingDescription, + compactDescription: l10n.restartOnboardingDescriptionCompact, + ), OutlinedButton.icon( onPressed: () async { await controller.setOnboardingCompleted(false); @@ -1302,7 +1411,7 @@ class _SettingsScreenState extends State { contentPadding: EdgeInsets.zero, leading: const FaIcon(FontAwesomeIcons.discord, size: 22), title: Text(l10n.communityTitle), - subtitle: Text(l10n.communityDescription), + subtitle: _buildSettingSubtitle(l10n.communityDescription), trailing: const Icon(Icons.open_in_new_rounded), onTap: () => _openDiscordCommunity(context), ), @@ -1318,7 +1427,7 @@ class _SettingsScreenState extends State { contentPadding: EdgeInsets.zero, leading: const Icon(Icons.feedback_outlined), title: Text(l10n.feedbackTitle), - subtitle: Text(l10n.feedbackDescription), + subtitle: _buildSettingSubtitle(l10n.feedbackDescription), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).pushNamed(AppRoutes.feedback); diff --git a/package-lock.json b/package-lock.json index ca2e871..9a1b876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,8 +4,12 @@ "requires": true, "packages": { "": { + "hasInstallScript": true, "devDependencies": { "wrangler": "^4.21.0" + }, + "engines": { + "node": "^22.23.4 || ^24.22.1 || ^26.12.1" } }, "node_modules/@cloudflare/kv-asset-handler": { diff --git a/package.json b/package.json index b17a389..8712c22 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "private": true, "type": "module", + "engines": { + "node": "^22.23.4 || ^24.22.1 || ^26.12.1" + }, "scripts": { - "serve:web": "wrangler dev", + "preinstall": "node scripts/check-node-version.mjs", + "preserve:web": "node scripts/check-node-version.mjs", + "serve:web": "flutter run -d web-server --web-hostname=127.0.0.1 --web-port=8080", "predeploy:web": "node scripts/stamp-web-build.mjs", - "deploy:web": "wrangler deploy" + "deploy:web": "wrangler deploy", + "test:node": "node --test test/node_version_test.mjs" }, "devDependencies": { "wrangler": "^4.21.0" diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs new file mode 100644 index 0000000..5f6173b --- /dev/null +++ b/scripts/check-node-version.mjs @@ -0,0 +1,26 @@ +// CVE-2026-111497 is in Node's bundled native Brotli decoder. Updating npm +// packages alone does not fix it; each supported release line has its own floor. +const patchedVersions = new Map([ + [22, [23, 4]], + [24, [22, 1]], + [26, [12, 1]], +]); + +export function requireSafeNode(version = process.versions.node) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + const [major, minor, patch] = match ? match.slice(1).map(Number) : []; + const minimum = patchedVersions.get(major); + if ( + !minimum || + minor < minimum[0] || + (minor === minimum[0] && patch < minimum[1]) + ) { + throw new Error( + `Node.js ${version} is unsupported for CVE-2026-111497 (HTTP2Shell). ` + + 'Use Node.js 22.23.4+, 24.22.1+, or 26.12.1+ within those release lines; ' + + '.node-version selects the project default.', + ); + } +} + +requireSafeNode(); diff --git a/scripts/stamp-web-build.mjs b/scripts/stamp-web-build.mjs index 8da6840..cf707da 100644 --- a/scripts/stamp-web-build.mjs +++ b/scripts/stamp-web-build.mjs @@ -17,6 +17,7 @@ * Run after `flutter build web` and before deploying. `npm run deploy:web` * runs it automatically via the predeploy:web hook. */ +import './check-node-version.mjs'; import { execSync } from 'node:child_process'; import { existsSync, diff --git a/test/ad_settings_test.dart b/test/ad_settings_test.dart index 729ae0a..0f69e26 100644 --- a/test/ad_settings_test.dart +++ b/test/ad_settings_test.dart @@ -109,61 +109,69 @@ void main() { }); for (final width in [320.0, 780.0]) { - testWidgets('selector images and text fit at width $width', (tester) async { - final controller = await createAdTestController(); - addTearDown(controller.dispose); - tester.view.physicalSize = Size(width, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - MaterialApp( - home: AppControllerScope( - controller: controller, - child: MediaQuery( - data: MediaQueryData(textScaler: TextScaler.linear(1.8)), - child: const Scaffold( - body: SingleChildScrollView( - padding: EdgeInsets.all(20), - child: AdDensitySetting(), + for (final compactMode in [false, true]) { + testWidgets( + 'selector images and text fit at width $width with compact=$compactMode', + (tester) async { + final controller = await createAdTestController(); + addTearDown(controller.dispose); + if (compactMode) { + await controller.updateEnableCompactMode(true); + } + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: AppControllerScope( + controller: controller, + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(1.8)), + child: const Scaffold( + body: SingleChildScrollView( + padding: EdgeInsets.all(20), + child: AdDensitySetting(), + ), + ), ), ), ), - ), - ), - ); - const assets = ['unshock', 'shock', 'cringe', 'scared']; - const messages = [ - '感謝支持', - '哇 感謝你的支持<3', - '謝謝謝謝謝謝謝謝謝謝', - '哥們你真的很愛看廣告嗎 那很謝謝你了', - ]; - for (var level = 1; level <= 4; level++) { - await tester.tap(find.text('$level')); - await tester.pumpAndSettle(); - expect(controller.settings.adDensity, level); - final image = tester.widget(find.byType(Image)); - expect( - (image.image as AssetImage).assetName, - 'assets/cat_${assets[level - 1]}.jpg', - ); - expect(tester.getSize(find.byType(Image)), const Size(20, 20)); - final tile = tester.widget(find.byType(ListTile)); - expect(tile.subtitle, isA()); - expect((tile.subtitle as Text).textSpan, isA()); - expect(find.textContaining(messages[level - 1]), findsOneWidget); - expect(tester.takeException(), isNull); - } - await controller.updateEnableAds(false); - await tester.pumpAndSettle(); - expect( - tester - .widget>(find.byType(SegmentedButton)) - .onSelectionChanged, - isNull, + ); + const assets = ['unshock', 'shock', 'cringe', 'scared']; + const messages = [ + '感謝支持', + '哇 感謝你的支持<3', + '謝謝謝謝謝謝謝謝謝謝', + '哥們你真的很愛看廣告嗎 那很謝謝你了', + ]; + for (var level = 1; level <= 4; level++) { + await tester.tap(find.text('$level')); + await tester.pumpAndSettle(); + expect(controller.settings.adDensity, level); + final image = tester.widget(find.byType(Image)); + expect( + (image.image as AssetImage).assetName, + 'assets/cat_${assets[level - 1]}.jpg', + ); + expect(tester.getSize(find.byType(Image)), const Size(20, 20)); + final tile = tester.widget(find.byType(ListTile)); + expect(tile.subtitle, isA()); + expect((tile.subtitle as Text).textSpan, isA()); + expect(find.textContaining(messages[level - 1]), findsOneWidget); + expect(tester.takeException(), isNull); + } + await controller.updateEnableAds(false); + await tester.pumpAndSettle(); + expect( + tester + .widget>(find.byType(SegmentedButton)) + .onSelectionChanged, + isNull, + ); + expect(controller.settings.adDensity, 4); + }, ); - expect(controller.settings.adDensity, 4); - }); + } } } diff --git a/test/node_version_test.mjs b/test/node_version_test.mjs new file mode 100644 index 0000000..a74e7c4 --- /dev/null +++ b/test/node_version_test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { requireSafeNode } from '../scripts/check-node-version.mjs'; + +test('accepts the patched releases and later patches in each supported line', () => { + for (const version of ['22.23.4', '22.24.0', '24.22.1', '24.22.2', '26.12.1']) { + assert.doesNotThrow(() => requireSafeNode(version)); + } +}); + +test('blocks vulnerable, EOL, unsupported and prerelease runtimes', () => { + for (const version of [ + '20.20.0', '22.23.3', '24.2.0', '24.22.0', '25.9.0', '26.12.0', + '24.22.1-rc.1', 'invalid', + ]) { + assert.throws(() => requireSafeNode(version), /CVE-2026-111497/); + } +}); + +test('the checked-in default runtime passes the guard', () => { + const version = readFileSync(new URL('../.node-version', import.meta.url), 'utf8').trim(); + assert.doesNotThrow(() => requireSafeNode(version)); +}); diff --git a/test/settings_navigation_test.dart b/test/settings_navigation_test.dart index 555e1be..c21072e 100644 --- a/test/settings_navigation_test.dart +++ b/test/settings_navigation_test.dart @@ -15,6 +15,7 @@ import 'package:taiwanbus_flutter/screens/account_screen.dart'; import 'package:taiwanbus_flutter/screens/adaptive_settings_presenter.dart'; import 'package:taiwanbus_flutter/screens/database_settings_screen.dart'; import 'package:taiwanbus_flutter/screens/settings_screen.dart'; +import 'package:taiwanbus_flutter/widgets/ad_density_setting.dart'; import 'support/ad_test_controller.dart'; @@ -155,6 +156,164 @@ void main() { expect(tester.takeException(), isNull); }); + for (final locale in [const Locale('zh', 'TW'), const Locale('en')]) { + _settingsTest('compact descriptions switch with the setting in $locale', ( + tester, + ) async { + final controller = await _pumpHost(tester, height: 3000, locale: locale); + final l10n = lookupAppLocalizations(locale); + await _openCategory(tester, l10n.appearanceSectionTitle); + expect(find.text(l10n.languageDescription), findsOneWidget); + + final compact = find.widgetWithText( + SwitchListTile, + l10n.compactModeTitle, + ); + await tester.tap(compact); + await tester.pumpAndSettle(); + expect(find.text(l10n.languageDescriptionCompact), findsOneWidget); + expect(find.text(l10n.showWeatherDescriptionCompact), findsOneWidget); + expect(find.text(l10n.interfaceScaleDescriptionCompact), findsOneWidget); + expect(find.text(l10n.compactModeDescription), findsOneWidget); + expect(find.text(l10n.languageDescription), findsNothing); + expect(find.text(l10n.showWeatherDescription), findsNothing); + + await tester.tap( + find.widgetWithText(ListTile, l10n.personalizationTitle), + ); + await tester.pumpAndSettle(); + expect( + find.text(l10n.personalizationAmoledDescriptionCompact), + findsOneWidget, + ); + expect( + find.text(l10n.personalizationHomeGradientDescriptionCompact), + findsOneWidget, + ); + expect( + find.text(l10n.personalizationPerPageDescriptionCompact), + findsOneWidget, + ); + expect( + find.text(l10n.personalizationBackgroundImageDescriptionCompact), + findsOneWidget, + ); + expect(find.text(l10n.personalizationAmoledDescription), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + await controller.updateDatabaseAutoUpdateMode( + DatabaseAutoUpdateMode.cellularOnly, + ); + await tester.pumpAndSettle(); + expect( + find.textContaining(l10n.databaseAutoUpdateCellular), + findsOneWidget, + ); + await _openCategory(tester, l10n.usageAndUpdatesTitle); + expect( + find.text(l10n.alwaysShowSecondsDescriptionCompact), + findsOneWidget, + ); + expect( + find.text(l10n.normalUpdateIntervalDescriptionCompact), + findsOneWidget, + ); + expect(find.text(l10n.alwaysShowSecondsDescription), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + await _openCategory(tester, l10n.appearanceSectionTitle); + await tester.tap(compact); + await tester.pumpAndSettle(); + expect(controller.settings.enableCompactMode, isFalse); + expect(find.text(l10n.languageDescription), findsOneWidget); + expect(find.text(l10n.showWeatherDescription), findsOneWidget); + expect(find.text(l10n.languageDescriptionCompact), findsNothing); + expect(tester.takeException(), isNull); + }); + + _settingsTest( + 'compact ad descriptions follow enabled and locked states in $locale', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final controller = await _pumpHost( + tester, + height: 3000, + locale: locale, + ); + final l10n = lookupAppLocalizations(locale); + await controller.updateEnableCompactMode(true); + await tester.pumpAndSettle(); + await _openCategory(tester, l10n.usageAndUpdatesTitle); + final ads = find.widgetWithText(SwitchListTile, l10n.showAdsTitle); + final pleas = RegExp( + [ + l10n.adsPleaOne, + l10n.adsPleaTwo, + l10n.adsPleaThree, + l10n.adsPleaFour, + ].map(RegExp.escape).join('|'), + ); + expect(tester.widget(ads).value, isTrue); + expect(find.text(l10n.adsEnabledDescription), findsOneWidget); + expect(find.byType(AdDensitySetting), findsOneWidget); + + await tester.tap(ads); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(l10n.adsDisableDescription), findsOneWidget); + expect(controller.settings.enableAds, isTrue); + await tester.tap(find.text(l10n.adsDisableConfirm)); + await tester.pumpAndSettle(); + expect(tester.widget(ads).value, isFalse); + expect(find.textContaining(l10n.adsDisabledStatus), findsOneWidget); + expect( + find.descendant(of: ads, matching: find.textContaining(pleas)), + findsOneWidget, + ); + expect( + find.descendant(of: ads, matching: find.byType(Image)), + findsOneWidget, + ); + expect(find.text(l10n.adsEnabledDescription), findsNothing); + expect(find.byType(AdDensitySetting), findsNothing); + + await tester.tap(ads); + await tester.pumpAndSettle(); + expect(tester.widget(ads).value, isTrue); + expect(find.text(l10n.adsEnabledDescription), findsOneWidget); + expect(find.textContaining(l10n.adsDisabledStatus), findsNothing); + + for (var index = 0; index < 4; index++) { + await tester.tap(ads); + await tester.pumpAndSettle(); + } + expect(controller.settings.enableAds, isTrue); + expect(tester.widget(ads).onChanged, isNull); + expect( + find.textContaining(l10n.adsLockedMessageCompact), + findsOneWidget, + ); + expect( + find.descendant( + of: ads, + matching: find.textContaining(l10n.adsLockedMessage.trim()), + ), + findsOneWidget, + ); + expect( + find.descendant(of: ads, matching: find.byType(Image)), + findsOneWidget, + ); + expect(find.textContaining(l10n.adsDisabledStatus), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + _settingsTest('catalog and category scroll positions are restored', ( tester, ) async { @@ -279,35 +438,41 @@ void main() { }); for (final locale in [const Locale('zh', 'TW'), const Locale('en')]) { - _settingsTest( - 'all categories fit narrow screens with large text in $locale', - (tester) async { - await _pumpHost( - tester, - width: 320, - height: 568, - scale: 1.8, - locale: locale, - ); - final l10n = lookupAppLocalizations(locale); - for (final title in [ - l10n.appearanceSectionTitle, - l10n.usageAndUpdatesTitle, - if (!AppBuildInfo.isAabBuild) l10n.appUpdatesTitle, - l10n.historyPrivacyTitle, - l10n.onboardingSettingsTitle, - l10n.aboutTitle, - ]) { - await _openCategory(tester, title); - await tester.drag(find.byType(ListView), const Offset(0, -1200)); - await tester.pumpAndSettle(); + for (final compactMode in [false, true]) { + _settingsTest( + 'all categories fit narrow screens with large text in $locale with compact=$compactMode', + (tester) async { + final controller = await _pumpHost( + tester, + width: 320, + height: 568, + scale: 1.8, + locale: locale, + ); + if (compactMode) { + await controller.updateEnableCompactMode(true); + await tester.pumpAndSettle(); + } + final l10n = lookupAppLocalizations(locale); + for (final title in [ + l10n.appearanceSectionTitle, + l10n.usageAndUpdatesTitle, + if (!AppBuildInfo.isAabBuild) l10n.appUpdatesTitle, + l10n.historyPrivacyTitle, + l10n.onboardingSettingsTitle, + l10n.aboutTitle, + ]) { + await _openCategory(tester, title); + await tester.drag(find.byType(ListView), const Offset(0, -1200)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + } expect(tester.takeException(), isNull); - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - } - expect(tester.takeException(), isNull); - }, - ); + }, + ); + } } }