From 09a85755fa19f0e0b6eb62ddac7a063f374d8d0e Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sun, 4 Oct 2026 11:22:46 +0800 Subject: [PATCH 1/2] feat: organize settings into navigable categories --- lib/l10n/app_en.arb | 6 + lib/l10n/app_localizations.dart | 54 + lib/l10n/app_localizations_en.dart | 23 + lib/l10n/app_localizations_zh.dart | 36 + lib/l10n/app_zh.arb | 6 + lib/l10n/app_zh_TW.arb | 6 + lib/screens/settings_screen.dart | 1932 +++++++++--------- test/ad_settings_test.dart | 7 +- test/primary_surfaces_localization_test.dart | 12 +- test/settings_navigation_test.dart | 352 ++++ test/support/ad_test_controller.dart | 6 +- 11 files changed, 1462 insertions(+), 978 deletions(-) create mode 100644 test/settings_navigation_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dbea652..eab1f4c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,12 @@ { "@@locale": "en", "appTitle": "YetAnotherBusApp", + "settingsAppearanceDescription": "Language, theme, interface size and personalization", + "settingsUsageDescription": "Arrival refresh, smart recommendations and preferences", + "settingsUpdatesDescription": "Update channel, automatic checks and app updates", + "settingsHistoryDescription": "Search history, clearing data and privacy policy", + "settingsOnboardingDescription": "Run the first-time setup again", + "settingsAboutDescription": "Version information, contributors, community and feedback", "settingsTitle": "Settings", "appearanceSectionTitle": "Appearance", "languageLabel": "Language", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bf91509..788e464 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -105,6 +105,42 @@ abstract class AppLocalizations { /// **'YetAnotherBusApp'** String get appTitle; + /// No description provided for @settingsAppearanceDescription. + /// + /// In en, this message translates to: + /// **'Language, theme, interface size and personalization'** + String get settingsAppearanceDescription; + + /// No description provided for @settingsUsageDescription. + /// + /// In en, this message translates to: + /// **'Arrival refresh, smart recommendations and preferences'** + String get settingsUsageDescription; + + /// No description provided for @settingsUpdatesDescription. + /// + /// In en, this message translates to: + /// **'Update channel, automatic checks and app updates'** + String get settingsUpdatesDescription; + + /// No description provided for @settingsHistoryDescription. + /// + /// In en, this message translates to: + /// **'Search history, clearing data and privacy policy'** + String get settingsHistoryDescription; + + /// No description provided for @settingsOnboardingDescription. + /// + /// In en, this message translates to: + /// **'Run the first-time setup again'** + String get settingsOnboardingDescription; + + /// No description provided for @settingsAboutDescription. + /// + /// In en, this message translates to: + /// **'Version information, contributors, community and feedback'** + String get settingsAboutDescription; + /// No description provided for @settingsTitle. /// /// In en, this message translates to: @@ -2762,21 +2798,39 @@ abstract class AppLocalizations { String get routeDetailOperationsNotice; /// No description provided for @routeAlertAffectedRoute. + /// + /// In en, this message translates to: + /// **'Affected route'** String get routeAlertAffectedRoute; /// No description provided for @routeAlertType. + /// + /// In en, this message translates to: + /// **'Type'** String get routeAlertType; /// No description provided for @routeAlertTime. + /// + /// In en, this message translates to: + /// **'Time'** String get routeAlertTime; /// No description provided for @routeAlertImpact. + /// + /// In en, this message translates to: + /// **'Impact'** String get routeAlertImpact; /// No description provided for @routeAlertSource. + /// + /// In en, this message translates to: + /// **'Official source'** String get routeAlertSource; /// No description provided for @routeAlertOpenSource. + /// + /// In en, this message translates to: + /// **'Open official source'** String get routeAlertOpenSource; /// No description provided for @routeDetailViewRoutePresence. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3192629..b301e2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -11,6 +11,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appTitle => 'YetAnotherBusApp'; + @override + String get settingsAppearanceDescription => + 'Language, theme, interface size and personalization'; + + @override + String get settingsUsageDescription => + 'Arrival refresh, smart recommendations and preferences'; + + @override + String get settingsUpdatesDescription => + 'Update channel, automatic checks and app updates'; + + @override + String get settingsHistoryDescription => + 'Search history, clearing data and privacy policy'; + + @override + String get settingsOnboardingDescription => 'Run the first-time setup again'; + + @override + String get settingsAboutDescription => + 'Version information, contributors, community and feedback'; + @override String get settingsTitle => 'Settings'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9db0e80..e2a045c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -11,6 +11,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appTitle => 'YetAnotherBusApp'; + @override + String get settingsAppearanceDescription => '語言、主題、介面大小與個人化'; + + @override + String get settingsUsageDescription => '到站資訊更新、智慧推薦與使用偏好'; + + @override + String get settingsUpdatesDescription => '更新頻道、自動檢查與版本更新'; + + @override + String get settingsHistoryDescription => '搜尋紀錄、資料清除與隱私政策'; + + @override + String get settingsOnboardingDescription => '重新進行初次使用設定'; + + @override + String get settingsAboutDescription => '版本資訊、貢獻者、社群與意見回饋'; + @override String get settingsTitle => '設定'; @@ -3232,6 +3250,24 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appTitle => 'YetAnotherBusApp'; + @override + String get settingsAppearanceDescription => '語言、主題、介面大小與個人化'; + + @override + String get settingsUsageDescription => '到站資訊更新、智慧推薦與使用偏好'; + + @override + String get settingsUpdatesDescription => '更新頻道、自動檢查與版本更新'; + + @override + String get settingsHistoryDescription => '搜尋紀錄、資料清除與隱私政策'; + + @override + String get settingsOnboardingDescription => '重新進行初次使用設定'; + + @override + String get settingsAboutDescription => '版本資訊、貢獻者、社群與意見回饋'; + @override String get settingsTitle => '設定'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5e7c426..4802fe5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,6 +1,12 @@ { "@@locale": "zh", "appTitle": "YetAnotherBusApp", + "settingsAppearanceDescription": "語言、主題、介面大小與個人化", + "settingsUsageDescription": "到站資訊更新、智慧推薦與使用偏好", + "settingsUpdatesDescription": "更新頻道、自動檢查與版本更新", + "settingsHistoryDescription": "搜尋紀錄、資料清除與隱私政策", + "settingsOnboardingDescription": "重新進行初次使用設定", + "settingsAboutDescription": "版本資訊、貢獻者、社群與意見回饋", "settingsTitle": "設定", "appearanceSectionTitle": "外觀", "languageLabel": "語言", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index cb14a95..a3b7d1d 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1,6 +1,12 @@ { "@@locale": "zh_TW", "appTitle": "YetAnotherBusApp", + "settingsAppearanceDescription": "語言、主題、介面大小與個人化", + "settingsUsageDescription": "到站資訊更新、智慧推薦與使用偏好", + "settingsUpdatesDescription": "更新頻道、自動檢查與版本更新", + "settingsHistoryDescription": "搜尋紀錄、資料清除與隱私政策", + "settingsOnboardingDescription": "重新進行初次使用設定", + "settingsAboutDescription": "版本資訊、貢獻者、社群與意見回饋", "settingsTitle": "設定", "appearanceSectionTitle": "外觀", "languageLabel": "語言", diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bfee039..63d879f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -24,6 +24,16 @@ import 'database_settings_screen.dart'; import 'personalization_screen.dart'; import '../widgets/background_image_wrapper.dart'; +enum _SettingsCategory { + appearance, + wearOs, + usage, + appUpdates, + history, + onboarding, + about, +} + class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -45,6 +55,8 @@ class _SettingsScreenState extends State { 'Steven0925': Uri.parse('https://github.com/Steven0925'), }; + final _pageStorageBucket = PageStorageBucket(); + _SettingsCategory? _selectedCategory; late Future _wearSyncStatusFuture; int _adToggleCount = 0; bool _adToggleLocked = false; @@ -238,11 +250,13 @@ class _SettingsScreenState extends State { children: [ Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), const SizedBox(width: 6), - Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], + Flexible( + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), ), ], @@ -277,1024 +291,996 @@ class _SettingsScreenState extends State { ); } + void _openCategory(_SettingsCategory category) { + setState(() => _selectedCategory = category); + } + + void _returnToCategories() { + setState(() => _selectedCategory = null); + } + + String _categoryTitle(AppLocalizations l10n, _SettingsCategory category) => + switch (category) { + _SettingsCategory.appearance => l10n.appearanceSectionTitle, + _SettingsCategory.wearOs => 'Wear OS', + _SettingsCategory.usage => l10n.usageAndUpdatesTitle, + _SettingsCategory.appUpdates => l10n.appUpdatesTitle, + _SettingsCategory.history => l10n.historyPrivacyTitle, + _SettingsCategory.onboarding => l10n.onboardingSettingsTitle, + _SettingsCategory.about => l10n.aboutTitle, + }; + + Widget _buildCategoryTile({ + required IconData icon, + required String title, + required String description, + required VoidCallback onTap, + }) { + final colors = Theme.of(context).colorScheme; + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: colors.onPrimaryContainer), + ), + title: Text(title), + subtitle: Text(description), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } + + List _buildCategories(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + final authSession = controller.authSession; + final isAndroid = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + return [ + _buildCategoryTile( + icon: Icons.palette_outlined, + title: l10n.appearanceSectionTitle, + description: l10n.settingsAppearanceDescription, + onTap: () => _openCategory(_SettingsCategory.appearance), + ), + _buildCategoryTile( + icon: Icons.account_circle_outlined, + title: l10n.accountTitle, + description: authSession == null + ? l10n.accountSignedOut + : l10n.accountSignedInAs( + authSession.displayName.isEmpty + ? authSession.provider + : authSession.displayName, + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: AppRoutes.account), + builder: (_) => const AccountScreen(), + ), + ), + ), + if (isAndroid) + FutureBuilder( + future: _wearSyncStatusFuture, + builder: (context, snapshot) { + final status = snapshot.data; + if (status == null || !status.hasConnectedNodes) { + return const SizedBox.shrink(); + } + return _buildCategoryTile( + icon: Icons.watch_outlined, + title: 'Wear OS', + description: l10n.wearSyncDescription, + onTap: () => _openCategory(_SettingsCategory.wearOs), + ); + }, + ), + if (!kIsWeb) + _buildCategoryTile( + icon: Icons.storage_rounded, + title: l10n.databaseDownloadsTitle, + description: [ + l10n.databaseCurrentRegion(controller.settings.provider.label), + l10n.databaseStartupUpdate( + localizedDatabaseAutoUpdateMode( + l10n, + controller.settings.databaseAutoUpdateMode, + ), + ), + if (controller.hasPendingDatabaseUpdates) + l10n.databasePendingRegions( + controller.pendingDatabaseUpdates.length, + ), + ].join('\n'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: AppRoutes.databaseSettings), + builder: (_) => const DatabaseSettingsScreen(), + ), + ), + ), + _buildCategoryTile( + icon: Icons.tune_rounded, + title: l10n.usageAndUpdatesTitle, + description: l10n.settingsUsageDescription, + onTap: () => _openCategory(_SettingsCategory.usage), + ), + if (!kIsWeb && !AppBuildInfo.isAabBuild) + _buildCategoryTile( + icon: Icons.system_update_alt_rounded, + title: l10n.appUpdatesTitle, + description: l10n.settingsUpdatesDescription, + onTap: () => _openCategory(_SettingsCategory.appUpdates), + ), + _buildCategoryTile( + icon: Icons.privacy_tip_outlined, + title: l10n.historyPrivacyTitle, + description: l10n.settingsHistoryDescription, + onTap: () => _openCategory(_SettingsCategory.history), + ), + _buildCategoryTile( + icon: Icons.restart_alt_rounded, + title: l10n.onboardingSettingsTitle, + description: l10n.settingsOnboardingDescription, + onTap: () => _openCategory(_SettingsCategory.onboarding), + ), + _buildCategoryTile( + icon: Icons.info_outline_rounded, + title: l10n.aboutTitle, + description: l10n.settingsAboutDescription, + onTap: () => _openCategory(_SettingsCategory.about), + ), + ]; + } + + Widget _buildCategory(BuildContext context, _SettingsCategory category) => + switch (category) { + _SettingsCategory.appearance => _buildAppearance(context), + _SettingsCategory.wearOs => _buildWearOs(context), + _SettingsCategory.usage => _buildUsage(context), + _SettingsCategory.appUpdates => _buildAppUpdates(context), + _SettingsCategory.history => _buildHistory(context), + _SettingsCategory.onboarding => _buildOnboarding(context), + _SettingsCategory.about => _buildAbout(context), + }; + @override Widget build(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + final category = _selectedCategory; + final hasSettingsBackgroundImage = hasBackgroundImageForPage( + controller.settings, + pageKey: 'settings', + ); + + return PopScope( + canPop: category == null, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _selectedCategory != null) { + _returnToCategories(); + } + }, + child: BackgroundImageWrapper( + pageKey: 'settings', + child: Scaffold( + backgroundColor: hasSettingsBackgroundImage + ? Colors.transparent + : null, + appBar: AppBar( + title: Text( + category == null + ? l10n.settingsTitle + : _categoryTitle(l10n, category), + ), + leading: category == null + ? null + : BackButton(onPressed: _returnToCategories), + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 860), + child: PageStorage( + bucket: _pageStorageBucket, + child: ListView( + key: PageStorageKey(category?.name ?? 'categories'), + primary: false, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + children: [ + if (category == null) + ..._buildCategories(context) + else + _buildCategory(context, category), + const AdBannerWidget(minimumDensity: 4, isInline: true), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildAppearance(BuildContext context) { final controller = AppControllerScope.of(context); final l10n = AppLocalizations.of(context); final selectableLocales = AppLocalizations.supportedLocales .where((locale) => !_fallbackOnlyLocales.contains(locale)) .toList(growable: false); final selectedLanguage = controller.settings.language; - final buildInfo = controller.buildInfo; final theme = Theme.of(context); - final hasSettingsBackgroundImage = hasBackgroundImageForPage( - controller.settings, - pageKey: 'settings', - ); final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; final isIOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; - final supportsRouteBackgroundMonitor = isAndroid || isIOS; - final authSession = controller.authSession; - // final databaseProviders = controller.selectedProviders - // .map((provider) => provider.label) - // .join('、'); - - return BackgroundImageWrapper( - pageKey: 'settings', - child: Scaffold( - backgroundColor: hasSettingsBackgroundImage ? Colors.transparent : null, - appBar: AppBar(title: Text(l10n.settingsTitle)), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 860), - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.appearanceSectionTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - AppDropdownFormField( - isExpanded: true, - initialValue: controller.settings.language, - decoration: InputDecoration( - labelText: l10n.languageLabel, - ), - items: [ - DropdownMenuItem( - value: null, - child: Text(l10n.languageSystem), - ), - ...selectableLocales.map( - (locale) => DropdownMenuItem( - value: locale, - child: Text( - lookupAppLocalizations(locale).languageName, - ), - ), - ), - // Keep an existing fallback-locale preference - // selectable until the user chooses another one. - if (selectedLanguage != null && - _fallbackOnlyLocales.contains(selectedLanguage)) - DropdownMenuItem( - value: selectedLanguage, - child: Text( - lookupAppLocalizations( - selectedLanguage, - ).languageName, - ), - ), - ], - onChanged: controller.updateLanguage, - ), - const SizedBox(height: 16), - Text( - '${l10n.interfaceScaleLabel}: ' - '${l10n.interfaceScaleValue((controller.settings.interfaceScale * 100).round())}', - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text( - l10n.interfaceScaleDescription, - style: theme.textTheme.bodySmall, - ), - Slider( - min: AppSettings.minInterfaceScale, - max: AppSettings.maxInterfaceScale, - divisions: 5, - value: controller.settings.interfaceScale, - label: l10n.interfaceScaleValue( - (controller.settings.interfaceScale * 100).round(), - ), - semanticFormatterCallback: (value) => - l10n.interfaceScaleValue((value * 100).round()), - onChanged: controller.updateInterfaceScale, - ), - const SizedBox(height: 4), - AppDropdownFormField( - isExpanded: true, - initialValue: controller.settings.themeMode, - decoration: InputDecoration( - labelText: l10n.themeModeLabel, - ), - items: [ - DropdownMenuItem( - value: ThemeMode.system, - child: Text(l10n.themeModeSystem), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text(l10n.themeModeLight), - ), - DropdownMenuItem( - value: ThemeMode.dark, - child: Text(l10n.themeModeDark), - ), - ], - onChanged: (value) { - if (value != null) { - controller.updateThemeMode(value); - } - }, - ), - const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.compactModeTitle), - subtitle: Text(l10n.compactModeDescription), - value: controller.settings.enableCompactMode, - onChanged: controller.updateEnableCompactMode, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.showWeatherTitle), - subtitle: Text(l10n.showWeatherDescription), - value: controller.settings.showWeatherInAppBar, - onChanged: controller.updateShowWeatherInAppBar, - ), - if (isAndroid || isIOS) ...[ - const SizedBox(height: 12), - AppDropdownFormField( - isExpanded: true, - initialValue: controller.settings.mobileMapProvider, - decoration: InputDecoration( - labelText: l10n.mapProviderLabel, - ), - items: MobileMapProvider.values - .map( - (provider) => - DropdownMenuItem( - value: provider, - child: Text(provider.label), - ), - ) - .toList(growable: false), - onChanged: (value) { - if (value != null) { - controller.updateMobileMapProvider(value); - } - }, - ), - ], - const SizedBox(height: 12), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.palette_outlined), - title: Text(l10n.personalizationTitle), - subtitle: Text(l10n.personalizationDescription), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - settings: const RouteSettings( - name: '/personalization', - ), - builder: (_) => const PersonalizationScreen(), - ), - ); - }, - ), - ], - ), + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppDropdownFormField( + isExpanded: true, + initialValue: controller.settings.language, + decoration: InputDecoration(labelText: l10n.languageLabel), + items: [ + DropdownMenuItem( + value: null, + child: Text(l10n.languageSystem), + ), + ...selectableLocales.map( + (locale) => DropdownMenuItem( + value: locale, + child: Text(lookupAppLocalizations(locale).languageName), ), ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.account_circle_outlined), - title: Text(l10n.accountTitle), - subtitle: Text( - authSession == null - ? l10n.accountSignedOut - : l10n.accountSignedInAs( - authSession.displayName.isEmpty - ? authSession.provider - : authSession.displayName, - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - settings: const RouteSettings( - name: AppRoutes.account, - ), - builder: (_) => const AccountScreen(), - ), - ); - }, + // Keep an existing fallback-locale preference + // selectable until the user chooses another one. + if (selectedLanguage != null && + _fallbackOnlyLocales.contains(selectedLanguage)) + DropdownMenuItem( + value: selectedLanguage, + child: Text( + lookupAppLocalizations(selectedLanguage).languageName, ), ), + ], + onChanged: controller.updateLanguage, + ), + const SizedBox(height: 16), + Text( + '${l10n.interfaceScaleLabel}: ' + '${l10n.interfaceScaleValue((controller.settings.interfaceScale * 100).round())}', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + l10n.interfaceScaleDescription, + style: theme.textTheme.bodySmall, + ), + Slider( + min: AppSettings.minInterfaceScale, + max: AppSettings.maxInterfaceScale, + divisions: 5, + value: controller.settings.interfaceScale, + label: l10n.interfaceScaleValue( + (controller.settings.interfaceScale * 100).round(), + ), + semanticFormatterCallback: (value) => + l10n.interfaceScaleValue((value * 100).round()), + onChanged: controller.updateInterfaceScale, + ), + const SizedBox(height: 4), + AppDropdownFormField( + isExpanded: true, + initialValue: controller.settings.themeMode, + decoration: InputDecoration(labelText: l10n.themeModeLabel), + items: [ + DropdownMenuItem( + value: ThemeMode.system, + child: Text(l10n.themeModeSystem), ), - if (isAndroid) ...[ - const SizedBox(height: 12), - FutureBuilder( - future: _wearSyncStatusFuture, - builder: (context, snapshot) { - final status = snapshot.data; - if (snapshot.connectionState == ConnectionState.waiting || - (status == null || !status.hasConnectedNodes)) { - return const SizedBox.shrink(); - } + DropdownMenuItem( + value: ThemeMode.light, + child: Text(l10n.themeModeLight), + ), + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(l10n.themeModeDark), + ), + ], + onChanged: (value) { + if (value != null) { + controller.updateThemeMode(value); + } + }, + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.compactModeTitle), + subtitle: Text(l10n.compactModeDescription), + value: controller.settings.enableCompactMode, + onChanged: controller.updateEnableCompactMode, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.showWeatherTitle), + subtitle: Text(l10n.showWeatherDescription), + value: controller.settings.showWeatherInAppBar, + onChanged: controller.updateShowWeatherInAppBar, + ), + if (isAndroid || isIOS) ...[ + const SizedBox(height: 12), + AppDropdownFormField( + isExpanded: true, + initialValue: controller.settings.mobileMapProvider, + decoration: InputDecoration(labelText: l10n.mapProviderLabel), + items: MobileMapProvider.values + .map( + (provider) => DropdownMenuItem( + value: provider, + child: Text(provider.label), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value != null) { + controller.updateMobileMapProvider(value); + } + }, + ), + ], + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.palette_outlined), + title: Text(l10n.personalizationTitle), + subtitle: Text(l10n.personalizationDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: '/personalization'), + builder: (_) => const PersonalizationScreen(), + ), + ); + }, + ), + ], + ), + ), + ); + } - final groupNames = controller.favoriteGroupNames; - final hasFavorites = groupNames.isNotEmpty; - final selectedIds = controller - .settings - .wearSelectedFavoriteIds - .toSet(); + Widget _buildWearOs(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + return FutureBuilder( + future: _wearSyncStatusFuture, + builder: (context, snapshot) { + final status = snapshot.data; + if (snapshot.connectionState == ConnectionState.waiting || + (status == null || !status.hasConnectedNodes)) { + return const SizedBox.shrink(); + } - // Get all available favorite IDs across all groups - final availableIds = []; - for (final favorites - in controller.favoriteGroups.values) { - for (final fav in favorites) { - availableIds.add(fav.stableKey); - } - } - final availableSet = availableIds.toSet(); + final groupNames = controller.favoriteGroupNames; + final hasFavorites = groupNames.isNotEmpty; + final selectedIds = controller.settings.wearSelectedFavoriteIds.toSet(); - String? selectedValue; - if (selectedIds.isEmpty) { - selectedValue = null; - } else if (setEquals(selectedIds, availableSet)) { - selectedValue = '__all__'; - } else { - // Check if it matches any specific group - for (final groupName in groupNames) { - final groupStableKeys = - controller.favoriteGroups[groupName] - ?.map((e) => e.stableKey) - .toSet() ?? - const {}; - if (groupStableKeys.isNotEmpty && - setEquals(selectedIds, groupStableKeys)) { - selectedValue = groupName; - break; - } - } - } + // Get all available favorite IDs across all groups + final availableIds = []; + for (final favorites in controller.favoriteGroups.values) { + for (final fav in favorites) { + availableIds.add(fav.stableKey); + } + } + final availableSet = availableIds.toSet(); - return Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Wear OS', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - status.connectedNodeCount > 1 - ? l10n.wearConnectedWatches( - localizedList( - l10n, - status.connectedNodeNames, - ), - status.connectedNodeCount, - ) - : l10n.wearConnectedWatch( - localizedList( - l10n, - status.connectedNodeNames, - ), - ), - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.wearSyncTitle), - subtitle: Text(l10n.wearSyncDescription), - value: controller.settings.wearSyncEnabled, - onChanged: controller.updateWearSyncEnabled, - ), - if (controller.settings.wearSyncEnabled) ...[ - if (!hasFavorites) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - l10n.wearNoFavorites, - style: Theme.of( - context, - ).textTheme.bodyMedium, - ), - ) - else ...[ - const SizedBox(height: 8), - AppDropdownFormField( - isExpanded: true, - initialValue: selectedValue, - decoration: InputDecoration( - labelText: l10n.wearSyncCategory, - ), - items: [ - DropdownMenuItem( - value: '__all__', - child: Text(l10n.wearAllCategories), - ), - ...groupNames.map( - (groupName) => DropdownMenuItem( - value: groupName, - child: Text(groupName), - ), - ), - ], - onChanged: (value) { - if (value == '__all__') { - controller - .updateWearSelectedFavoriteIds( - availableIds, - ); - } else if (value != null) { - final keys = - controller.favoriteGroups[value] - ?.map((e) => e.stableKey) - .toList() ?? - const []; - controller - .updateWearSelectedFavoriteIds( - keys, - ); - } - }, - ), - ], - ], - ], - ), + String? selectedValue; + if (selectedIds.isEmpty) { + selectedValue = null; + } else if (setEquals(selectedIds, availableSet)) { + selectedValue = '__all__'; + } else { + // Check if it matches any specific group + for (final groupName in groupNames) { + final groupStableKeys = + controller.favoriteGroups[groupName] + ?.map((e) => e.stableKey) + .toSet() ?? + const {}; + if (groupStableKeys.isNotEmpty && + setEquals(selectedIds, groupStableKeys)) { + selectedValue = groupName; + break; + } + } + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + status.connectedNodeCount > 1 + ? l10n.wearConnectedWatches( + localizedList(l10n, status.connectedNodeNames), + status.connectedNodeCount, + ) + : l10n.wearConnectedWatch( + localizedList(l10n, status.connectedNodeNames), ), - ); - }, - ), - ], - if (!kIsWeb) ...[ - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.databaseDownloadsTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - l10n.databaseCurrentRegion( - controller.settings.provider.label, - ), - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - Text( - l10n.databaseStartupUpdate( - localizedDatabaseAutoUpdateMode( - l10n, - controller.settings.databaseAutoUpdateMode, - ), - ), - style: Theme.of(context).textTheme.bodyMedium, - ), - if (controller.hasPendingDatabaseUpdates) ...[ - const SizedBox(height: 4), - Text( - l10n.databasePendingRegions( - controller.pendingDatabaseUpdates.length, - ), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - const SizedBox(height: 14), - FilledButton.tonalIcon( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - settings: const RouteSettings( - name: AppRoutes.databaseSettings, - ), - builder: (_) => - const DatabaseSettingsScreen(), - ), - ); - }, - icon: const Icon(Icons.storage_rounded), - label: Text(l10n.databaseOpenPage), - ), - ], - ), - ), - ), - ], + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.usageAndUpdatesTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.alwaysShowSecondsTitle), - subtitle: Text( - l10n.alwaysShowSecondsDescription, - style: const TextStyle( - decoration: TextDecoration.lineThrough, - ), - ), - value: controller.settings.alwaysShowSeconds, - onChanged: controller.updateAlwaysShowSeconds, - ), - if (isAndroid || isIOS) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.hapticFeedbackTitle), - subtitle: Text(l10n.hapticFeedbackDescription), - value: controller.settings.enableHapticFeedback, - onChanged: controller.updateEnableHapticFeedback, - ), - if (!kIsWeb && - defaultTargetPlatform == TargetPlatform.android) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.showAdsTitle), - subtitle: _adToggleLocked - ? Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: - PlaceholderAlignment.middle, - child: Image.asset( - "assets/cat_laugh.png", - width: 20, - height: 20, - ), - ), - TextSpan(text: l10n.adsLockedMessage), - ], - ), - ) - : controller.settings.enableAds - ? Text(l10n.adsEnabledDescription) - : Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: - PlaceholderAlignment.middle, - child: Image.asset( - [ - "assets/cat_cry.png", - "assets/cat_sad.png", - ][Random().nextInt(2)], - width: 20, - height: 20, - ), - ), - TextSpan( - text: - ' ${[l10n.adsPleaOne, l10n.adsPleaTwo, l10n.adsPleaThree, l10n.adsPleaFour][Random().nextInt(4)]}', - ), - ], - ), - ), - value: _adToggleLocked - ? true - : controller.settings.enableAds, - onChanged: _adToggleLocked - ? null - : (value) => _handleAdToggle( - context, - controller, - value, - ), - ), - // 廣告濃度只在廣告開啟時才有意義,關閉廣告時整項隱藏。 - if (!kIsWeb && - defaultTargetPlatform == TargetPlatform.android && - controller.settings.enableAds) - const AdDensitySetting(), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.smartRecommendationsTitle), - subtitle: Text(l10n.smartRecommendationsDescription), - value: controller.settings.enableSmartRecommendations, - onChanged: - controller.updateEnableSmartRecommendations, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.autoFavoriteTitle), - subtitle: Text(l10n.autoFavoriteDescription), - value: controller - .settings - .enableAutoFavoriteFrequentStops, - onChanged: - controller.updateEnableAutoFavoriteFrequentStops, - ), - if (isAndroid) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.smartNotificationTitle), - subtitle: Text(l10n.smartNotificationDescription), - value: controller - .settings - .enableSmartRouteNotifications, - onChanged: (value) => - _toggleSmartRouteNotifications( - context, - controller, - value, - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.keepScreenAwakeTitle), - subtitle: Text(l10n.keepScreenAwakeDescription), - value: - controller.settings.keepScreenAwakeOnRouteDetail, - onChanged: - controller.updateKeepScreenAwakeOnRouteDetail, - ), - if (supportsRouteBackgroundMonitor) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.backgroundTripTitle), - subtitle: Text( - isIOS - ? l10n.backgroundTripIosDescription - : l10n.backgroundTripDescription, - ), - value: controller - .settings - .enableRouteBackgroundMonitor, - onChanged: (value) { - controller.updateEnableRouteBackgroundMonitor( - value, - ); - }, - ), - if (isAndroid) ...[ - const SizedBox(height: 12), - AppDropdownFormField( - isExpanded: true, - initialValue: controller - .settings - .favoriteWidgetAutoRefreshMinutes, - decoration: InputDecoration( - labelText: l10n.favoriteWidgetRefreshLabel, - helperText: l10n.favoriteWidgetRefreshHelper, - ), - items: _favoriteWidgetRefreshOptions - .map( - (minutes) => DropdownMenuItem( - value: minutes, - child: Text( - _favoriteWidgetRefreshLabel( - l10n, - minutes, - ), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - controller - .updateFavoriteWidgetAutoRefreshMinutes( - value, - ); - } - }, - ), - ], - const SizedBox(height: 8), - Text( - l10n.normalUpdateInterval( - controller.settings.busUpdateTime, - ), - ), - Slider( - min: 5, - max: 60, - divisions: 11, - value: controller.settings.busUpdateTime.toDouble(), - label: l10n.secondsValue( - controller.settings.busUpdateTime, - ), - onChanged: (value) { - controller.updateBusUpdateTime(value.round()); - }, - ), - const SizedBox(height: 8), - Text( - l10n.retryInterval( - controller.settings.busErrorUpdateTime, - ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.wearSyncTitle), + subtitle: Text(l10n.wearSyncDescription), + value: controller.settings.wearSyncEnabled, + onChanged: controller.updateWearSyncEnabled, + ), + if (controller.settings.wearSyncEnabled) ...[ + if (!hasFavorites) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.wearNoFavorites, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else ...[ + const SizedBox(height: 8), + AppDropdownFormField( + isExpanded: true, + initialValue: selectedValue, + decoration: InputDecoration( + labelText: l10n.wearSyncCategory, + ), + items: [ + DropdownMenuItem( + value: '__all__', + child: Text(l10n.wearAllCategories), ), - Slider( - min: 1, - max: 15, - divisions: 14, - value: controller.settings.busErrorUpdateTime - .toDouble(), - label: l10n.secondsValue( - controller.settings.busErrorUpdateTime, + ...groupNames.map( + (groupName) => DropdownMenuItem( + value: groupName, + child: Text(groupName), ), - onChanged: (value) { - controller.updateBusErrorUpdateTime(value.round()); - }, ), ], + onChanged: (value) { + if (value == '__all__') { + controller.updateWearSelectedFavoriteIds( + availableIds, + ); + } else if (value != null) { + final keys = + controller.favoriteGroups[value] + ?.map((e) => e.stableKey) + .toList() ?? + const []; + controller.updateWearSelectedFavoriteIds(keys); + } + }, ), - ), - ), - if (!kIsWeb && !AppBuildInfo.isAabBuild) ...[ - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.appUpdatesTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - AppDropdownFormField( - isExpanded: true, - initialValue: controller.settings.appUpdateChannel, - decoration: InputDecoration( - labelText: l10n.updateChannelLabel, - ), - items: AppUpdateChannel.values - .map( - (channel) => DropdownMenuItem( - value: channel, - child: Text( - localizedAppUpdateChannel(l10n, channel), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - controller.updateAppUpdateChannel(value); - } - }, - ), - const SizedBox(height: 12), - AppDropdownFormField( - isExpanded: true, - initialValue: - controller.settings.appUpdateCheckMode, - decoration: InputDecoration( - labelText: l10n.updateCheckOnLaunchLabel, - ), - items: AppUpdateCheckMode.values - .map( - (mode) => DropdownMenuItem( - value: mode, - child: Text( - localizedAppUpdateCheckMode(l10n, mode), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - controller.updateAppUpdateCheckMode(value); - } - }, - ), - const SizedBox(height: 12), - Text( - localizedAppUpdateChannelDescription( - l10n, - controller.settings.appUpdateChannel, - ), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text( - localizedAppUpdateCheckModeDescription( - l10n, - controller.settings.appUpdateCheckMode, - ), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: controller.checkingAppUpdate - ? null - : () => _checkAppUpdate(context, controller), - icon: controller.checkingAppUpdate - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.system_update_alt_rounded), - label: Text( - controller.checkingAppUpdate - ? l10n.appUpdateChecking - : l10n.appUpdateCheckNow, - ), - ), - if (controller.lastAppUpdateResult - case final result?) ...[ - const SizedBox(height: 12), - Text( - l10n.appUpdateRecentResult( - localizedAppUpdateResult(l10n, result), + ], + ], + ], + ), + ), + ); + }, + ); + } + + Widget _buildUsage(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + final isAndroid = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + final isIOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + final supportsRouteBackgroundMonitor = isAndroid || isIOS; + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.alwaysShowSecondsTitle), + subtitle: Text( + l10n.alwaysShowSecondsDescription, + style: const TextStyle(decoration: TextDecoration.lineThrough), + ), + value: controller.settings.alwaysShowSeconds, + onChanged: controller.updateAlwaysShowSeconds, + ), + if (isAndroid || isIOS) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.hapticFeedbackTitle), + subtitle: Text(l10n.hapticFeedbackDescription), + value: controller.settings.enableHapticFeedback, + onChanged: controller.updateEnableHapticFeedback, + ), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.showAdsTitle), + subtitle: _adToggleLocked + ? Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image.asset( + "assets/cat_laugh.png", + width: 20, + height: 20, ), - style: Theme.of(context).textTheme.bodySmall, ), + TextSpan(text: l10n.adsLockedMessage), ], - ], - ), - ), - ), - ], - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.historyPrivacyTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - Text( - l10n.searchHistoryLimit( - controller.settings.maxHistory, - ), - ), - Slider( - min: 0, - max: 30, - divisions: 30, - value: controller.settings.maxHistory.toDouble(), - label: l10n.itemsValue( - controller.settings.maxHistory, - ), - onChanged: (value) { - controller.updateMaxHistory(value.round()); - }, ), - const SizedBox(height: 8), - Text( - l10n.smartRoutesCount( - controller.routeUsageProfiles.length, - ), - ), - const SizedBox(height: 4), - Text( - l10n.routeSelectionsCount( - controller.recordedRouteSelections, - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: 12, - runSpacing: 12, + ) + : controller.settings.enableAds + ? Text(l10n.adsEnabledDescription) + : Text.rich( + TextSpan( children: [ - OutlinedButton.icon( - onPressed: controller.clearHistory, - icon: const Icon(Icons.delete_outline_rounded), - label: Text(l10n.clearSearchHistory), - ), - OutlinedButton.icon( - onPressed: controller.clearRouteUsageProfiles, - icon: const Icon(Icons.psychology_alt_outlined), - label: Text(l10n.clearSmartHistory), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image.asset( + [ + "assets/cat_cry.png", + "assets/cat_sad.png", + ][Random().nextInt(2)], + width: 20, + height: 20, + ), ), - OutlinedButton.icon( - onPressed: controller.clearRouteSelectionHistory, - icon: const Icon(Icons.route_outlined), - label: Text(l10n.clearRouteSelectionHistory), + TextSpan( + text: + ' ${[l10n.adsPleaOne, l10n.adsPleaTwo, l10n.adsPleaThree, l10n.adsPleaFour][Random().nextInt(4)]}', ), ], ), - const SizedBox(height: 16), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.gavel_outlined), - title: Text(l10n.termsOfService), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of( - context, - ).pushNamed(AppRoutes.termsOfService); - }, - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.privacy_tip_outlined), - title: Text(l10n.privacyPolicy), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of( - context, - ).pushNamed(AppRoutes.privacyPolicy); - }, - ), - ], + ), + value: _adToggleLocked ? true : controller.settings.enableAds, + onChanged: _adToggleLocked + ? null + : (value) => _handleAdToggle(context, controller, value), + ), + // 廣告濃度只在廣告開啟時才有意義,關閉廣告時整項隱藏。 + if (!kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + controller.settings.enableAds) + const AdDensitySetting(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.smartRecommendationsTitle), + subtitle: Text(l10n.smartRecommendationsDescription), + value: controller.settings.enableSmartRecommendations, + onChanged: controller.updateEnableSmartRecommendations, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.autoFavoriteTitle), + subtitle: Text(l10n.autoFavoriteDescription), + value: controller.settings.enableAutoFavoriteFrequentStops, + onChanged: controller.updateEnableAutoFavoriteFrequentStops, + ), + if (isAndroid) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.smartNotificationTitle), + subtitle: Text(l10n.smartNotificationDescription), + value: controller.settings.enableSmartRouteNotifications, + onChanged: (value) => + _toggleSmartRouteNotifications(context, controller, value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.keepScreenAwakeTitle), + subtitle: Text(l10n.keepScreenAwakeDescription), + value: controller.settings.keepScreenAwakeOnRouteDetail, + onChanged: controller.updateKeepScreenAwakeOnRouteDetail, + ), + if (supportsRouteBackgroundMonitor) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.backgroundTripTitle), + subtitle: Text( + isIOS + ? l10n.backgroundTripIosDescription + : l10n.backgroundTripDescription, + ), + value: controller.settings.enableRouteBackgroundMonitor, + onChanged: (value) { + controller.updateEnableRouteBackgroundMonitor(value); + }, + ), + if (isAndroid) ...[ + const SizedBox(height: 12), + AppDropdownFormField( + isExpanded: true, + initialValue: + controller.settings.favoriteWidgetAutoRefreshMinutes, + decoration: InputDecoration( + labelText: l10n.favoriteWidgetRefreshLabel, + helperText: l10n.favoriteWidgetRefreshHelper, + ), + items: _favoriteWidgetRefreshOptions + .map( + (minutes) => DropdownMenuItem( + value: minutes, + child: Text(_favoriteWidgetRefreshLabel(l10n, minutes)), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + controller.updateFavoriteWidgetAutoRefreshMinutes(value); + } + }, + ), + ], + const SizedBox(height: 8), + Text(l10n.normalUpdateInterval(controller.settings.busUpdateTime)), + Slider( + min: 5, + max: 60, + divisions: 11, + value: controller.settings.busUpdateTime.toDouble(), + label: l10n.secondsValue(controller.settings.busUpdateTime), + onChanged: (value) { + controller.updateBusUpdateTime(value.round()); + }, + ), + const SizedBox(height: 8), + Text(l10n.retryInterval(controller.settings.busErrorUpdateTime)), + Slider( + min: 1, + max: 15, + divisions: 14, + value: controller.settings.busErrorUpdateTime.toDouble(), + label: l10n.secondsValue(controller.settings.busErrorUpdateTime), + onChanged: (value) { + controller.updateBusErrorUpdateTime(value.round()); + }, + ), + ], + ), + ), + ); + } + + Widget _buildAppUpdates(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppDropdownFormField( + isExpanded: true, + initialValue: controller.settings.appUpdateChannel, + decoration: InputDecoration(labelText: l10n.updateChannelLabel), + items: AppUpdateChannel.values + .map( + (channel) => DropdownMenuItem( + value: channel, + child: Text(localizedAppUpdateChannel(l10n, channel)), ), - ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + controller.updateAppUpdateChannel(value); + } + }, + ), + const SizedBox(height: 12), + AppDropdownFormField( + isExpanded: true, + initialValue: controller.settings.appUpdateCheckMode, + decoration: InputDecoration( + labelText: l10n.updateCheckOnLaunchLabel, + ), + items: AppUpdateCheckMode.values + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(localizedAppUpdateCheckMode(l10n, mode)), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + controller.updateAppUpdateCheckMode(value); + } + }, + ), + const SizedBox(height: 12), + Text( + localizedAppUpdateChannelDescription( + l10n, + controller.settings.appUpdateChannel, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + localizedAppUpdateCheckModeDescription( + l10n, + controller.settings.appUpdateCheckMode, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: controller.checkingAppUpdate + ? null + : () => _checkAppUpdate(context, controller), + icon: controller.checkingAppUpdate + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update_alt_rounded), + label: Text( + controller.checkingAppUpdate + ? l10n.appUpdateChecking + : l10n.appUpdateCheckNow, + ), + ), + if (controller.lastAppUpdateResult case final result?) ...[ + const SizedBox(height: 12), + Text( + l10n.appUpdateRecentResult( + localizedAppUpdateResult(l10n, result), ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.onboardingSettingsTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () async { - await controller.setOnboardingCompleted(false); - if (!context.mounted) { - return; - } - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.restart_alt_rounded), - label: Text(l10n.restartOnboarding), - ), - ], + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ); + } + + Widget _buildHistory(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.searchHistoryLimit(controller.settings.maxHistory)), + Slider( + min: 0, + max: 30, + divisions: 30, + value: controller.settings.maxHistory.toDouble(), + label: l10n.itemsValue(controller.settings.maxHistory), + onChanged: (value) { + controller.updateMaxHistory(value.round()); + }, + ), + const SizedBox(height: 8), + Text(l10n.smartRoutesCount(controller.routeUsageProfiles.length)), + const SizedBox(height: 4), + Text(l10n.routeSelectionsCount(controller.recordedRouteSelections)), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton.icon( + onPressed: controller.clearHistory, + icon: const Icon(Icons.delete_outline_rounded), + label: Text(l10n.clearSearchHistory), + ), + OutlinedButton.icon( + onPressed: controller.clearRouteUsageProfiles, + icon: const Icon(Icons.psychology_alt_outlined), + label: Text(l10n.clearSmartHistory), + ), + OutlinedButton.icon( + onPressed: controller.clearRouteSelectionHistory, + icon: const Icon(Icons.route_outlined), + label: Text(l10n.clearRouteSelectionHistory), + ), + ], + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.gavel_outlined), + title: Text(l10n.termsOfService), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).pushNamed(AppRoutes.termsOfService); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.privacy_tip_outlined), + title: Text(l10n.privacyPolicy), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).pushNamed(AppRoutes.privacyPolicy); + }, + ), + ], + ), + ), + ); + } + + Widget _buildOnboarding(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: () async { + await controller.setOnboardingCompleted(false); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.restart_alt_rounded), + label: Text(l10n.restartOnboarding), + ), + ], + ), + ), + ); + } + + Widget _buildAbout(BuildContext context) { + final controller = AppControllerScope.of(context); + final l10n = AppLocalizations.of(context); + final buildInfo = controller.buildInfo; + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 85, + height: 85, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + 'assets/branding/icon.svg', + width: 45, + height: 45, + semanticsLabel: 'YABus', + colorFilter: ColorFilter.mode( + theme.colorScheme.onPrimaryContainer, + BlendMode.srcIn, ), ), ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.aboutTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 85, - height: 85, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(22), - ), - alignment: Alignment.center, - child: SvgPicture.asset( - 'assets/branding/icon.svg', - width: 45, - height: 45, - semanticsLabel: 'YABus', - colorFilter: ColorFilter.mode( - theme.colorScheme.onPrimaryContainer, - BlendMode.srcIn, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'YABus', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - 'YetAnotherBusApp', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildAboutChip( - theme, - icon: Icons.sell_outlined, - label: buildInfo.displayVersion, - ), - _buildAboutChip( - theme, - icon: Icons.commit_rounded, - label: buildInfo.shortGitSha, - ), - ], - ), - ], - ), - ), - ], - ), - const Divider(height: 32), - Text( - l10n.contributorsTitle, - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final contributor - in _contributorGithubUris.entries) - _buildContributorChip( - context, - theme, - contributor.key, - contributor.value, - ), - ], + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'YABus', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), - const Divider(height: 32), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const FaIcon( - FontAwesomeIcons.discord, - size: 22, - ), - title: Text(l10n.communityTitle), - subtitle: Text(l10n.communityDescription), - trailing: const Icon(Icons.open_in_new_rounded), - onTap: () => _openDiscordCommunity(context), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const FaIcon( - FontAwesomeIcons.instagram, - size: 22, - ), - title: const Text('Instagram'), - subtitle: const Text('@yabus.tw'), - trailing: const Icon(Icons.open_in_new_rounded), - onTap: () => _openInstagram(context), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.feedback_outlined), - title: Text(l10n.feedbackTitle), - subtitle: Text(l10n.feedbackDescription), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).pushNamed(AppRoutes.feedback); - }, + ), + const SizedBox(height: 2), + Text( + 'YetAnotherBusApp', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ], - ), + ), + ], ), ), - const AdBannerWidget(minimumDensity: 4, isInline: true), ], ), - ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildAboutChip( + theme, + icon: Icons.sell_outlined, + label: buildInfo.displayVersion, + ), + _buildAboutChip( + theme, + icon: Icons.commit_rounded, + label: buildInfo.shortGitSha, + ), + ], + ), + const Divider(height: 32), + Text(l10n.contributorsTitle, style: theme.textTheme.titleSmall), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final contributor in _contributorGithubUris.entries) + _buildContributorChip( + context, + theme, + contributor.key, + contributor.value, + ), + ], + ), + const Divider(height: 32), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const FaIcon(FontAwesomeIcons.discord, size: 22), + title: Text(l10n.communityTitle), + subtitle: Text(l10n.communityDescription), + trailing: const Icon(Icons.open_in_new_rounded), + onTap: () => _openDiscordCommunity(context), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const FaIcon(FontAwesomeIcons.instagram, size: 22), + title: const Text('Instagram'), + subtitle: const Text('@yabus.tw'), + trailing: const Icon(Icons.open_in_new_rounded), + onTap: () => _openInstagram(context), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.feedback_outlined), + title: Text(l10n.feedbackTitle), + subtitle: Text(l10n.feedbackDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).pushNamed(AppRoutes.feedback); + }, + ), + ], ), ), ); } } - -// SettingsScreen diff --git a/test/ad_settings_test.dart b/test/ad_settings_test.dart index 328e753..729ae0a 100644 --- a/test/ad_settings_test.dart +++ b/test/ad_settings_test.dart @@ -67,8 +67,7 @@ void main() { try { final controller = await createAdTestController(); addTearDown(controller.dispose); - // A tall viewport keeps the whole settings list built so the density - // section can be found without scrolling. + // A tall viewport keeps all controls in the usage category visible. tester.view.physicalSize = const Size(900, 8000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -87,6 +86,10 @@ void main() { ); await tester.pumpAndSettle(); + expect(find.byType(AdDensitySetting), findsNothing); + await tester.tap(find.text('使用與更新')); + await tester.pumpAndSettle(); + expect(controller.settings.enableAds, isTrue); expect(find.text('廣告濃度'), findsOneWidget); diff --git a/test/primary_surfaces_localization_test.dart b/test/primary_surfaces_localization_test.dart index bea398e..01b50bd 100644 --- a/test/primary_surfaces_localization_test.dart +++ b/test/primary_surfaces_localization_test.dart @@ -113,7 +113,7 @@ void main() { ); testWidgets( - 'settings localize and scroll at narrow width in ${language.locale}', + 'settings categories and details localize at narrow width in ${language.locale}', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.windows; final controller = await _createController(); @@ -128,12 +128,22 @@ void main() { expect(find.text(language.settings), findsOneWidget); expect(find.text(language.appearance), findsOneWidget); + expect(find.byType(Slider), findsNothing); + expect(find.byType(SwitchListTile), findsNothing); await tester.scrollUntilVisible( find.text(language.usage), 400, scrollable: find.byType(Scrollable).first, ); expect(find.text(language.usage), findsOneWidget); + await tester.tap(find.text(language.usage)); + await tester.pump(); + expect(find.text(language.usage), findsOneWidget); + expect(find.byType(SwitchListTile), findsWidgets); + expect(find.text(language.appearance), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pump(); + expect(find.text(language.settings), findsOneWidget); expect(tester.takeException(), isNull); } finally { await tester.pumpWidget(const SizedBox.shrink()); diff --git a/test/settings_navigation_test.dart b/test/settings_navigation_test.dart new file mode 100644 index 0000000..0d55f1c --- /dev/null +++ b/test/settings_navigation_test.dart @@ -0,0 +1,352 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:taiwanbus_flutter/app/bus_app.dart'; +import 'package:taiwanbus_flutter/core/ad_service.dart'; +import 'package:taiwanbus_flutter/core/app_build_info.dart'; +import 'package:taiwanbus_flutter/core/app_controller.dart'; +import 'package:taiwanbus_flutter/core/bus_repository.dart'; +import 'package:taiwanbus_flutter/core/models.dart'; +import 'package:taiwanbus_flutter/core/storage_service.dart'; +import 'package:taiwanbus_flutter/l10n/app_localizations.dart'; +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 'support/ad_test_controller.dart'; + +const _wearChannel = MethodChannel('tw.avianjay.taiwanbus.flutter/wear_os'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_wearChannel, null); + }); + + for (final width in [390.0, 1400.0]) { + _settingsTest('category and system back navigation at width $width', ( + tester, + ) async { + await _pumpHost(tester, width: width); + expect( + find.byType(Dialog), + width >= 1100 ? findsOneWidget : findsNothing, + ); + expect(find.text('設定'), findsOneWidget); + expect(find.byType(SwitchListTile), findsNothing); + expect(find.byType(Slider), findsNothing); + + await _openCategory(tester, '外觀'); + expect(find.text('外觀'), findsOneWidget); + expect(find.text('設定'), findsNothing); + expect(find.byType(SwitchListTile), findsWidgets); + expect(find.text('使用與更新'), findsNothing); + expect( + find.byType(Dialog), + width >= 1100 ? findsOneWidget : findsNothing, + ); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('設定'), findsOneWidget); + expect(find.byType(SwitchListTile), findsNothing); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsNothing); + expect(find.text('Open settings'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + _settingsTest('restart onboarding closes all settings at width $width', ( + tester, + ) async { + final controller = await _pumpHost(tester, width: width); + expect(controller.needsOnboarding, isFalse); + await _openCategory(tester, '開始流程'); + await tester.tap(find.text('重新執行開始流程')); + await tester.pumpAndSettle(); + expect(controller.needsOnboarding, isTrue); + expect( + (await StorageService().loadSettings()).hasCompletedOnboarding, + isFalse, + ); + expect(find.byType(SettingsScreen), findsNothing); + expect(find.byType(Dialog), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + _settingsTest('settings persist when moving between categories', ( + tester, + ) async { + final controller = await _pumpHost(tester); + await _openCategory(tester, '外觀'); + final compact = find.widgetWithText(SwitchListTile, '精簡模式'); + await tester.ensureVisible(compact); + await tester.tap(compact); + await tester.pumpAndSettle(); + expect(controller.settings.enableCompactMode, isTrue); + expect((await StorageService().loadSettings()).enableCompactMode, isTrue); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + await _openCategory(tester, '使用與更新'); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + await _openCategory(tester, '外觀'); + expect(tester.widget(compact).value, isTrue); + expect(tester.takeException(), isNull); + }); + + _settingsTest('catalog and category scroll positions are restored', ( + tester, + ) async { + await _pumpHost(tester, width: 320, height: 400); + final usage = find.widgetWithText(ListTile, '使用與更新'); + await tester.scrollUntilVisible(usage, 200); + await Scrollable.ensureVisible(tester.element(usage), alignment: 0.5); + await tester.pumpAndSettle(); + final catalogOffset = _scrollOffset(tester); + expect(catalogOffset, greaterThan(0)); + await tester.tap(usage); + await tester.pumpAndSettle(); + expect(_scrollOffset(tester), 0); + await tester.drag(find.byType(ListView), const Offset(0, -650)); + await tester.pumpAndSettle(); + final usageOffset = _scrollOffset(tester); + expect(usageOffset, greaterThan(0)); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(_scrollOffset(tester), closeTo(catalogOffset, 1)); + await tester.tap(usage); + await tester.pumpAndSettle(); + expect(_scrollOffset(tester), closeTo(usageOffset, 1)); + expect(tester.takeException(), isNull); + }); + + _settingsTest('account and database categories open existing pages', ( + tester, + ) async { + await _pumpHost(tester); + await _openCategory(tester, '帳戶'); + expect(find.byType(AccountScreen), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('設定'), findsOneWidget); + await _openCategory(tester, '資料庫與下載'); + expect(find.byType(DatabaseSettingsScreen), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('設定'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final platform in [ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.windows, + ]) { + for (final connected in [false, true]) { + _settingsTest( + 'Wear OS visibility on $platform with connected=$connected', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_wearChannel, (call) async { + if (call.method == 'getWearSyncStatus') { + return { + 'connectedNodeCount': connected ? 1 : 0, + 'connectedNodeNames': connected + ? ['Test Watch'] + : [], + }; + } + return null; + }); + await _pumpHost(tester); + final visible = platform == TargetPlatform.android && connected; + expect(find.text('Wear OS'), visible ? findsOneWidget : findsNothing); + expect( + find.text('App 更新'), + AppBuildInfo.isAabBuild ? findsNothing : findsOneWidget, + ); + if (visible) { + await _openCategory(tester, 'Wear OS'); + expect(find.textContaining('Test Watch'), findsOneWidget); + expect( + find.widgetWithText(SwitchListTile, '啓用 Wear OS 同步'), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + _settingsTest('ad toggle confirmation and count survive category changes', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final controller = await _pumpHost(tester); + for (var index = 0; index < 6; index++) { + await _openCategory(tester, '使用與更新'); + final ads = find.widgetWithText(SwitchListTile, '顯示廣告'); + await tester.ensureVisible(ads); + await tester.tap(ads); + await tester.pumpAndSettle(); + if (index == 0) { + expect(find.byType(AlertDialog), findsOneWidget); + final l10n = AppLocalizations.of( + tester.element(find.byType(AlertDialog)), + ); + await tester.tap(find.text(l10n.adsDisableConfirm)); + await tester.pumpAndSettle(); + } else { + expect(find.byType(AlertDialog), findsNothing); + } + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + } + expect(controller.settings.enableAds, isTrue); + expect(await AdService.instance.isAdToggleLocked(), isTrue); + await _openCategory(tester, '使用與更新'); + expect( + tester + .widget(find.widgetWithText(SwitchListTile, '顯示廣告')) + .onChanged, + isNull, + ); + expect(tester.takeException(), isNull); + }); + + 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(); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }, + ); + } +} + +double _scrollOffset(WidgetTester tester) => tester + .state(find.byType(Scrollable).first) + .position + .pixels; + +Future _openCategory(WidgetTester tester, String title) async { + final tile = find.widgetWithText(ListTile, title); + await tester.scrollUntilVisible( + tile, + 250, + scrollable: find.byType(Scrollable).first, + ); + await Scrollable.ensureVisible(tester.element(tile), alignment: 0.5); + await tester.pumpAndSettle(); + await tester.tap(tile); + await tester.pumpAndSettle(); +} + +Future _pumpHost( + WidgetTester tester, { + double width = 390, + double height = 844, + double scale = 1, + Locale locale = const Locale('zh', 'TW'), +}) async { + final controller = await createAdTestController( + repository: _SettingsRepository(), + ); + await controller.setOnboardingCompleted(true); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + AppControllerScope( + controller: controller, + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: FilledButton( + onPressed: () => openAdaptiveSettingsScreen(context), + child: const Text('Open settings'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open settings')); + await tester.pumpAndSettle(); + return controller; +} + +void _settingsTest(String description, WidgetTesterCallback test) { + testWidgets(description, (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await test(tester); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); +} + +class _SettingsRepository extends BusRepository { + @override + Future getLocalVersion(BusProvider provider) async => null; +} diff --git a/test/support/ad_test_controller.dart b/test/support/ad_test_controller.dart index 43c3856..8b3d4f5 100644 --- a/test/support/ad_test_controller.dart +++ b/test/support/ad_test_controller.dart @@ -12,7 +12,9 @@ import 'package:taiwanbus_flutter/core/bus_repository.dart'; import 'package:taiwanbus_flutter/core/models.dart'; import 'package:taiwanbus_flutter/core/storage_service.dart'; -Future createAdTestController() async { +Future createAdTestController({ + BusRepository? repository, +}) async { const buildInfo = AppBuildInfo( version: '1.0.0', buildNumber: '1', @@ -29,7 +31,7 @@ Future createAdTestController() async { debugDefaultTargetPlatformOverride = previousPlatform; } return AppController( - repository: BusRepository(client: client), + repository: repository ?? BusRepository(client: client), storage: StorageService(), analytics: analytics, buildInfo: buildInfo, From 60bd488df3e7715c7a92f87a0db77f67469925fd Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sun, 4 Oct 2026 12:49:30 +0800 Subject: [PATCH 2/2] fix: preserve desktop settings dismissal and update locale tests --- lib/screens/settings_screen.dart | 5 ++- test/bus_app_locale_scale_test.dart | 6 +++ test/settings_navigation_test.dart | 63 +++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 63d879f..531eba3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -457,13 +457,16 @@ class _SettingsScreenState extends State { final controller = AppControllerScope.of(context); final l10n = AppLocalizations.of(context); final category = _selectedCategory; + final isDialog = ModalRoute.of(context) is PopupRoute; final hasSettingsBackgroundImage = hasBackgroundImageForPage( controller.settings, pageKey: 'settings', ); return PopScope( - canPop: category == null, + // Scrim clicks and Escape dismiss the dialog. Category back is handled + // by the app bar there, and by system back on full-page settings. + canPop: isDialog || category == null, onPopInvokedWithResult: (didPop, result) { if (!didPop && _selectedCategory != null) { _returnToCategories(); diff --git a/test/bus_app_locale_scale_test.dart b/test/bus_app_locale_scale_test.dart index 368c324..2de579d 100644 --- a/test/bus_app_locale_scale_test.dart +++ b/test/bus_app_locale_scale_test.dart @@ -97,6 +97,9 @@ void main() { Navigator.of(context).pushNamed(AppRoutes.settings); await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + final dropdown = find.byType(AppDropdownFormField); expect(dropdown, findsOneWidget); expect( @@ -171,6 +174,9 @@ void main() { ).pushNamed(AppRoutes.settings); await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, '外觀')); + await tester.pumpAndSettle(); + final dropdown = find.byType(AppDropdownFormField); expect( tester diff --git a/test/settings_navigation_test.dart b/test/settings_navigation_test.dart index 0d55f1c..555e1be 100644 --- a/test/settings_navigation_test.dart +++ b/test/settings_navigation_test.dart @@ -34,7 +34,7 @@ void main() { }); for (final width in [390.0, 1400.0]) { - _settingsTest('category and system back navigation at width $width', ( + _settingsTest('category back and settings dismissal at width $width', ( tester, ) async { await _pumpHost(tester, width: width); @@ -56,7 +56,11 @@ void main() { width >= 1100 ? findsOneWidget : findsNothing, ); - await tester.binding.handlePopRoute(); + if (width >= 1100) { + await tester.tap(find.byType(BackButton)); + } else { + await tester.binding.handlePopRoute(); + } await tester.pumpAndSettle(); expect(find.text('設定'), findsOneWidget); expect(find.byType(SwitchListTile), findsNothing); @@ -87,6 +91,48 @@ void main() { }); } + for (final dismissWithEscape in [false, true]) { + for (final openCategory in [false, true]) { + _settingsTest( + 'desktop dismiss via ${dismissWithEscape ? 'Escape' : 'scrim'} with category=$openCategory', + (tester) async { + await _pumpHost(tester, width: 1400); + if (openCategory) { + await _openCategory(tester, '外觀'); + } + + if (dismissWithEscape) { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + } else { + await tester.tapAt(const Offset(10, 10)); + } + await tester.pumpAndSettle(); + + expect(find.byType(SettingsScreen), findsNothing); + expect(find.byType(Dialog), findsNothing); + expect(find.text('Open settings'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + } + + _settingsTest('full-page settings preserve category back at desktop width', ( + tester, + ) async { + await _pumpHost(tester, width: 1400, adaptive: false); + expect(find.byType(Dialog), findsNothing); + await _openCategory(tester, '外觀'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('設定'), findsOneWidget); + expect(find.byType(SwitchListTile), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsNothing); + expect(tester.takeException(), isNull); + }); + _settingsTest('settings persist when moving between categories', ( tester, ) async { @@ -289,6 +335,7 @@ Future _pumpHost( double height = 844, double scale = 1, Locale locale = const Locale('zh', 'TW'), + bool adaptive = true, }) async { final controller = await createAdTestController( repository: _SettingsRepository(), @@ -319,7 +366,17 @@ Future _pumpHost( body: Builder( builder: (context) => Center( child: FilledButton( - onPressed: () => openAdaptiveSettingsScreen(context), + onPressed: () { + if (adaptive) { + openAdaptiveSettingsScreen(context); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SettingsScreen(), + ), + ); + } + }, child: const Text('Open settings'), ), ),