From a7c590ab78d6390e0b5ebdac7efa60850347ed13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Mon, 6 Apr 2026 01:23:40 +0800 Subject: [PATCH 01/14] fix: pin app search bar on whitelist page --- lib/pages/whitelist_page.dart | 46 +++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/pages/whitelist_page.dart b/lib/pages/whitelist_page.dart index 7608ee12..c91c5eef 100644 --- a/lib/pages/whitelist_page.dart +++ b/lib/pages/whitelist_page.dart @@ -344,24 +344,50 @@ class WhitelistPageState extends State { ], ), - // 说明 + 搜索栏 + // 说明文本(随列表滚动,不吸顶) SliverToBoxAdapter( - child: Container( + child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: AppListSearchHeader( - countText: _ctrl.showSystemApps + child: Text( + _ctrl.showSystemApps ? l10n.enabledAppsCountWithSystem(enabledCount) : l10n.enabledAppsCount(enabledCount), - showCountText: true, - searchController: _searchCtrl, - searchFocusNode: _searchFocus, - hintText: l10n.searchApps, - onChanged: _ctrl.setSearch, - onClear: _clearSearch, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant), ), ), ), + // 搜索栏(仅搜索框吸顶) + SliverPersistentHeader( + pinned: true, + delegate: FixedSliverHeaderDelegate( + height: 60, + minHeight: 60, + builder: (context, overlapsContent, collapseProgress) => + Material( + color: overlapsContent + ? cs.surfaceContainerLow + : cs.surface, + surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, + elevation: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: AppListSearchHeader( + countText: '', + showCountText: false, + searchController: _searchCtrl, + searchFocusNode: _searchFocus, + hintText: l10n.searchApps, + onChanged: _ctrl.setSearch, + onClear: _clearSearch, + ), + ), + ), + ), + ), + // 内容区 if (_ctrl.loading) const SliverFillRemaining( From 2c0cf1f1672dfafe1529e10ad794a3f3c7bc3e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Mon, 6 Apr 2026 01:40:39 +0800 Subject: [PATCH 02/14] fix: align pinned search container color with app bar overlay --- lib/pages/whitelist_page.dart | 23 +++++++++++++++++------ lib/widgets/app_list_widgets.dart | 6 +++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/pages/whitelist_page.dart b/lib/pages/whitelist_page.dart index c91c5eef..3afd1d19 100644 --- a/lib/pages/whitelist_page.dart +++ b/lib/pages/whitelist_page.dart @@ -365,11 +365,18 @@ class WhitelistPageState extends State { delegate: FixedSliverHeaderDelegate( height: 60, minHeight: 60, - builder: (context, overlapsContent, collapseProgress) => - Material( - color: overlapsContent - ? cs.surfaceContainerLow - : cs.surface, + builder: (context, overlapsContent, collapseProgress) { + final appBarTheme = Theme.of(context).appBarTheme; + final elevation = appBarTheme.scrolledUnderElevation ?? 3.0; + final pinnedBg = overlapsContent + ? ElevationOverlay.applySurfaceTint( + cs.surface, + cs.surfaceTint, + elevation, + ) + : cs.surface; + return Material( + color: pinnedBg, surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, elevation: 0, child: Container( @@ -380,11 +387,15 @@ class WhitelistPageState extends State { searchController: _searchCtrl, searchFocusNode: _searchFocus, hintText: l10n.searchApps, + searchBarBackgroundColor: overlapsContent + ? Colors.white + : cs.surfaceContainerHighest, onChanged: _ctrl.setSearch, onClear: _clearSearch, ), ), - ), + ); + }, ), ), diff --git a/lib/widgets/app_list_widgets.dart b/lib/widgets/app_list_widgets.dart index 61001cc8..c0456a96 100644 --- a/lib/widgets/app_list_widgets.dart +++ b/lib/widgets/app_list_widgets.dart @@ -7,6 +7,7 @@ class AppListSearchHeader extends StatelessWidget { super.key, required this.countText, this.showCountText = true, + this.searchBarBackgroundColor, required this.searchController, required this.searchFocusNode, required this.hintText, @@ -16,6 +17,7 @@ class AppListSearchHeader extends StatelessWidget { final String countText; final bool showCountText; + final Color? searchBarBackgroundColor; final TextEditingController searchController; final FocusNode searchFocusNode; final String hintText; @@ -53,7 +55,9 @@ class AppListSearchHeader extends StatelessWidget { EdgeInsets.symmetric(horizontal: 16), ), elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(cs.surfaceContainerHighest), + backgroundColor: WidgetStatePropertyAll( + searchBarBackgroundColor ?? cs.surfaceContainerHighest, + ), constraints: const BoxConstraints(minHeight: 48, maxHeight: 48), ), ], From adc6d8b91d79250b1d861ebeaa8f5cd045f67e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Mon, 6 Apr 2026 10:41:11 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E6=A1=86=E9=A2=9C=E8=89=B2=E8=B7=9F=E9=9A=8F=E4=B8=BB=E9=A2=98?= =?UTF-8?q?;=20=E7=BB=9F=E4=B8=80=E8=BF=9B=E5=BA=A6=E6=9D=A1=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E6=95=B0=E5=AD=97=E6=A0=B7=E5=BC=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/ai_config_page.dart | 13 ++--- lib/pages/settings_page.dart | 43 +++++++++------- lib/pages/whitelist_page.dart | 2 +- .../plugins/GeneratedPluginRegistrant.java | 49 +++++++++++++++++++ temp_export/android/local.properties | 2 + temp_export/build/.last_build_id | 1 + .../.filecache | 1 + .../gen_l10n_inputs_and_outputs.json | 1 + .../gen_localizations.d | 1 + .../gen_localizations.stamp | 1 + .../outputs.json | 1 + 11 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java create mode 100644 temp_export/android/local.properties create mode 100644 temp_export/build/.last_build_id create mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache create mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json create mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d create mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp create mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart index 86f2f6c0..9758b9b5 100644 --- a/lib/pages/ai_config_page.dart +++ b/lib/pages/ai_config_page.dart @@ -402,15 +402,16 @@ class _AiConfigPageState extends State { l10n.aiTimeoutTitle, style: textTheme.titleMedium, ), - Text( - '${_aiTimeoutDraft}s', - style: textTheme.bodySmall?.copyWith( - color: cs.onSurfaceVariant, - ), - ), ], ), ), + Text( + '${_aiTimeoutDraft}s', + style: textTheme.bodyLarge?.copyWith( + color: cs.primary, + fontWeight: FontWeight.bold, + ), + ), ], ), SliderTheme( diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 91d5970d..380bc11f 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -749,8 +749,11 @@ class _SettingsPageState extends State { ), Text( l10n.marqueeSpeedLabel(_marqueeSpeedDraft), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: cs.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: cs.primary, + fontWeight: FontWeight.bold, + ), ), if (_marqueeSpeedDraft != 100) SizedBox( @@ -822,12 +825,30 @@ class _SettingsPageState extends State { ), child: Row( children: [ + Expanded( + child: SliderTheme( + data: ModernSliderTheme.theme(context), + child: Slider( + value: _bigIslandMaxWidthDraft + .toDouble(), + min: 500, + max: 1000, + divisions: 54, + onChanged: _onBigIslandMaxWidthChanged, + onChangeEnd: _persistBigIslandMaxWidth, + ), + ), + ), + const SizedBox(width: 8), Text( l10n.bigIslandMaxWidthLabel( _bigIslandMaxWidthDraft, ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: cs.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: cs.primary, + fontWeight: FontWeight.bold, + ), ), if (_bigIslandMaxWidthDraft != 600) SizedBox( @@ -849,20 +870,6 @@ class _SettingsPageState extends State { }, ), ), - Expanded( - child: SliderTheme( - data: ModernSliderTheme.theme(context), - child: Slider( - value: _bigIslandMaxWidthDraft - .toDouble(), - min: 500, - max: 1000, - divisions: 54, - onChanged: _onBigIslandMaxWidthChanged, - onChangeEnd: _persistBigIslandMaxWidth, - ), - ), - ), ], ), ), diff --git a/lib/pages/whitelist_page.dart b/lib/pages/whitelist_page.dart index 3afd1d19..8fcfca9f 100644 --- a/lib/pages/whitelist_page.dart +++ b/lib/pages/whitelist_page.dart @@ -388,7 +388,7 @@ class WhitelistPageState extends State { searchFocusNode: _searchFocus, hintText: l10n.searchApps, searchBarBackgroundColor: overlapsContent - ? Colors.white + ? cs.surface : cs.surfaceContainerHighest, onChanged: _ctrl.setSearch, onClear: _clearSearch, diff --git a/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 00000000..2036eb51 --- /dev/null +++ b/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,49 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); + } + } +} diff --git a/temp_export/android/local.properties b/temp_export/android/local.properties new file mode 100644 index 00000000..26c42579 --- /dev/null +++ b/temp_export/android/local.properties @@ -0,0 +1,2 @@ +sdk.dir=D:\\sdk +flutter.sdk=D:\\flutter \ No newline at end of file diff --git a/temp_export/build/.last_build_id b/temp_export/build/.last_build_id new file mode 100644 index 00000000..0564b2b2 --- /dev/null +++ b/temp_export/build/.last_build_id @@ -0,0 +1 @@ +5cd85789d3dc5a767acee27062755a16 \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache b/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache new file mode 100644 index 00000000..bf4e2f57 --- /dev/null +++ b/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache @@ -0,0 +1 @@ +{"version":2,"files":[{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart","hash":"3fc71b0a4962b588b2607d23d7243b1c"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","hash":"693c3864fa610943dd99f7035a89dd69"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","hash":"3cb4cedacac63fa179b9317dd67fdde1"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb","hash":"f7a0c4a07d177c311ccb2263018499e5"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","hash":"ab96450101d1d79140b101f8a712ae47"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","hash":"69afef66c917bc8b45c6f552d6705355"},{"path":"D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","hash":"348c0ed9a8a043925174cd683c0a0770"},{"path":"D:\\Code\\hyper_island\\temp_export\\l10n.yaml","hash":"dab38dc0e22d47a73cb6382b6bb72eff"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","hash":"14b30471e403c4355e6acc38f5a7507f"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","hash":"33a276900ad78ff1cd267a3483f69235"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","hash":"5fbb496af9baaa0670086acdd69dd9a7"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","hash":"99db03a1ba18bd9d11404ca05da1e750"}]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json new file mode 100644 index 00000000..082c7724 --- /dev/null +++ b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json @@ -0,0 +1 @@ +{"inputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb"],"outputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d new file mode 100644 index 00000000..2b2aea85 --- /dev/null +++ b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d @@ -0,0 +1 @@ + D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart: D:\\Code\\hyper_island\\temp_export\\l10n.yaml D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp new file mode 100644 index 00000000..d2d93127 --- /dev/null +++ b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp @@ -0,0 +1 @@ +{"inputs":["D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","D:\\Code\\hyper_island\\temp_export\\l10n.yaml","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb"],"outputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json b/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json new file mode 100644 index 00000000..4733ba32 --- /dev/null +++ b/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json @@ -0,0 +1 @@ +["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"] \ No newline at end of file From 46d06e63b4a44f15491836e254e5bd04b04468d1 Mon Sep 17 00:00:00 2001 From: 1812z <2023158207@qq.com> Date: Mon, 6 Apr 2026 18:23:50 +0800 Subject: [PATCH 04/14] Remove temp --- .../plugins/GeneratedPluginRegistrant.java | 49 ------------------- temp_export/android/local.properties | 2 - temp_export/build/.last_build_id | 1 - .../.filecache | 1 - .../gen_l10n_inputs_and_outputs.json | 1 - .../gen_localizations.d | 1 - .../gen_localizations.stamp | 1 - .../outputs.json | 1 - 8 files changed, 57 deletions(-) delete mode 100644 temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java delete mode 100644 temp_export/android/local.properties delete mode 100644 temp_export/build/.last_build_id delete mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache delete mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json delete mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d delete mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp delete mode 100644 temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json diff --git a/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java deleted file mode 100644 index 2036eb51..00000000 --- a/temp_export/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.flutter.plugins; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import io.flutter.Log; - -import io.flutter.embedding.engine.FlutterEngine; - -/** - * Generated file. Do not edit. - * This file is generated by the Flutter tool based on the - * plugins that support the Android platform. - */ -@Keep -public final class GeneratedPluginRegistrant { - private static final String TAG = "GeneratedPluginRegistrant"; - public static void registerWith(@NonNull FlutterEngine flutterEngine) { - try { - flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); - } - } -} diff --git a/temp_export/android/local.properties b/temp_export/android/local.properties deleted file mode 100644 index 26c42579..00000000 --- a/temp_export/android/local.properties +++ /dev/null @@ -1,2 +0,0 @@ -sdk.dir=D:\\sdk -flutter.sdk=D:\\flutter \ No newline at end of file diff --git a/temp_export/build/.last_build_id b/temp_export/build/.last_build_id deleted file mode 100644 index 0564b2b2..00000000 --- a/temp_export/build/.last_build_id +++ /dev/null @@ -1 +0,0 @@ -5cd85789d3dc5a767acee27062755a16 \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache b/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache deleted file mode 100644 index bf4e2f57..00000000 --- a/temp_export/build/5cd85789d3dc5a767acee27062755a16/.filecache +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"files":[{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart","hash":"3fc71b0a4962b588b2607d23d7243b1c"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","hash":"693c3864fa610943dd99f7035a89dd69"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","hash":"3cb4cedacac63fa179b9317dd67fdde1"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb","hash":"f7a0c4a07d177c311ccb2263018499e5"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","hash":"ab96450101d1d79140b101f8a712ae47"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","hash":"69afef66c917bc8b45c6f552d6705355"},{"path":"D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","hash":"348c0ed9a8a043925174cd683c0a0770"},{"path":"D:\\Code\\hyper_island\\temp_export\\l10n.yaml","hash":"dab38dc0e22d47a73cb6382b6bb72eff"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","hash":"14b30471e403c4355e6acc38f5a7507f"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","hash":"33a276900ad78ff1cd267a3483f69235"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","hash":"5fbb496af9baaa0670086acdd69dd9a7"},{"path":"D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","hash":"99db03a1ba18bd9d11404ca05da1e750"}]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json deleted file mode 100644 index 082c7724..00000000 --- a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_l10n_inputs_and_outputs.json +++ /dev/null @@ -1 +0,0 @@ -{"inputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb"],"outputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d deleted file mode 100644 index 2b2aea85..00000000 --- a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.d +++ /dev/null @@ -1 +0,0 @@ - D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart: D:\\Code\\hyper_island\\temp_export\\l10n.yaml D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp b/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp deleted file mode 100644 index d2d93127..00000000 --- a/temp_export/build/5cd85789d3dc5a767acee27062755a16/gen_localizations.stamp +++ /dev/null @@ -1 +0,0 @@ -{"inputs":["D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","D:\\Code\\hyper_island\\temp_export\\l10n.yaml","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_en.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_ja.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_tr.arb","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\app_zh.arb"],"outputs":["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"]} \ No newline at end of file diff --git a/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json b/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json deleted file mode 100644 index 4733ba32..00000000 --- a/temp_export/build/5cd85789d3dc5a767acee27062755a16/outputs.json +++ /dev/null @@ -1 +0,0 @@ -["D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_en.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_ja.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_tr.dart","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations_zh.dart","D:\\Code\\hyper_island\\temp_export\\untranslated_messages.txt","D:\\Code\\hyper_island\\temp_export\\lib\\l10n\\generated\\app_localizations.dart"] \ No newline at end of file From 125db949208f5bb0d5c093adbb3c1ea2f36770be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Tue, 7 Apr 2026 12:29:12 +0800 Subject: [PATCH 05/14] migrate compose ui to miuix and fix overlay/channel issues --- android/app/build.gradle.kts | 26 +- android/app/src/main/AndroidManifest.xml | 22 +- .../hyperisland/NotificationChannelReader.kt | 214 +++ .../data/config/ConfigIoManager.kt | 81 ++ .../github/hyperisland/data/prefs/PrefKeys.kt | 40 + .../data/prefs/SettingsRepository.kt | 82 ++ .../hyperisland/data/prefs/SettingsState.kt | 27 + .../hyperisland/ui/ComposeMainActivity.kt | 1201 +++++++++++++++++ .../hyperisland/ui/ai/AiConfigRepository.kt | 42 + .../hyperisland/ui/ai/AiConfigScreen.kt | 233 ++++ .../github/hyperisland/ui/ai/AiConfigState.kt | 15 + .../hyperisland/ui/ai/AiConfigViewModel.kt | 130 ++ .../ui/app/AppAdaptationRepository.kt | 197 +++ .../hyperisland/ui/app/AppChannelsUiState.kt | 35 + .../ui/app/AppChannelsViewModel.kt | 187 +++ .../github/hyperisland/ui/app/AppsScreens.kt | 356 +++++ .../github/hyperisland/ui/app/AppsUiState.kt | 17 + .../hyperisland/ui/app/AppsViewModel.kt | 86 ++ .../ui/blacklist/BlacklistRepository.kt | 35 + .../ui/blacklist/BlacklistScreen.kt | 107 ++ .../ui/blacklist/BlacklistUiState.kt | 12 + .../ui/blacklist/BlacklistViewModel.kt | 95 ++ .../hyperisland/ui/blacklist/GamePresets.kt | 312 +++++ .../github/hyperisland/ui/home/HomeUiState.kt | 8 + .../hyperisland/ui/home/HomeViewModel.kt | 102 ++ .../ui/settings/SettingsViewModel.kt | 145 ++ .../app/src/main/res/values-night/styles.xml | 9 + android/app/src/main/res/values/styles.xml | 9 + android/settings.gradle.kts | 1 + compose_migration_plan.md | 62 + 30 files changed, 3882 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/NotificationChannelReader.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/data/config/ConfigIoManager.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigRepository.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigViewModel.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistRepository.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/GamePresets.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeUiState.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeViewModel.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt create mode 100644 compose_migration_plan.md diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f13ab86..f6312244 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,13 +3,14 @@ import java.util.Properties plugins { id("com.android.application") id("kotlin-android") + id("org.jetbrains.kotlin.plugin.compose") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { namespace = "io.github.hyperisland" - compileSdk = 36 + compileSdk = 37 ndkVersion = "27.0.12077973" compileOptions { @@ -17,6 +18,10 @@ android { targetCompatibility = JavaVersion.VERSION_21 } + buildFeatures { + compose = true + } + packaging { resources { merges += "META-INF/xposed/*" @@ -96,6 +101,25 @@ configurations.all { } dependencies { + val composeBom = platform("androidx.compose:compose-bom:2025.01.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + implementation("top.yukonga.miuix.kmp:miuix-ui-android:0.9.0") + implementation("top.yukonga.miuix.kmp:miuix-icons-android:0.9.0") + implementation("io.github.d4viddf:hyperisland_kit:0.4.3") compileOnly("io.github.libxposed:api:101.0.0") implementation("io.github.libxposed:service:101.0.0") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5032913f..ea886bc1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,9 +24,25 @@ android:description="@string/module_description" android:extractNativeLibs="true" android:icon="@mipmap/ic_launcher"> + + + + + + - - - - ? { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return emptyList() + val xml = readNotificationPolicyXml() + if (xml.isEmpty()) return null + val sanitized = sanitizeInvalidXml(xml) + val strict = runCatching { parseTextXmlChannels(sanitized, packageName) } + .onFailure { e -> Log.e(TAG, "strict parse failed for $packageName: ${e.message}", e) } + .getOrDefault(emptyList()) + if (strict.isNotEmpty()) return strict + + val fallback = runCatching { parseTextXmlChannelsFallback(sanitized, packageName) } + .onFailure { e -> Log.e(TAG, "fallback parse failed for $packageName: ${e.message}", e) } + .getOrNull() + return fallback ?: emptyList() + } + + private data class PackageFragment( + val content: String, + val hasClosingTag: Boolean, + ) + + private fun parseTextXmlChannelsFallback( + xml: String, + targetPkg: String, + ): List? { + val fragment = extractTargetPackageFragment(xml, targetPkg) ?: return null + val parser = android.util.Xml.newPullParser() + val wrappedXml = buildString { + append("") + append(fragment.content) + if (!fragment.hasClosingTag) append("") + append("") + } + + return runCatching { + parser.setInput(StringReader(wrappedXml)) + val channelsById = LinkedHashMap() + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "channel") { + buildChannel( + id = parser.getAttributeValue(null, "id"), + name = parser.getAttributeValue(null, "name"), + description = parser.getAttributeValue(null, "desc"), + importance = parser.getAttributeValue(null, "importance"), + importanceInt = parser.getAttributeValue(null, "importance-int"), + )?.let { channelsById.putIfAbsent(it.id, it) } + } + event = parser.next() + } + channelsById.values.toList() + }.getOrNull() + } + + private fun extractTargetPackageFragment(xml: String, targetPkg: String): PackageFragment? { + val pattern = Regex("""]*\bname\s*=\s*(["'])${Regex.escape(targetPkg)}\1[^>]*>""") + val startMatch = pattern.find(xml) ?: return null + val startIndex = startMatch.range.first + if (startMatch.value.trimEnd().endsWith("/>")) { + return PackageFragment(content = startMatch.value, hasClosingTag = true) + } + + val closingTag = "" + val closingIndex = xml.indexOf(closingTag, startIndex) + if (closingIndex >= 0) { + return PackageFragment( + content = xml.substring(startIndex, closingIndex + closingTag.length), + hasClosingTag = true, + ) + } + val nextPackageIndex = xml.indexOf("= 0) { + PackageFragment( + content = xml.substring(startIndex, nextPackageIndex), + hasClosingTag = false, + ) + } else { + PackageFragment(content = xml.substring(startIndex), hasClosingTag = false) + } + } + + private fun readNotificationPolicyXml(): String { + val result = RootShell.run("cat /data/system/notification_policy.xml") + if (result.exitCode != 0) return "" + val bytes = result.stdout + if (bytes.isEmpty()) return "" + if (AbxXmlDecoder.isAbx(bytes)) { + return try { + AbxXmlDecoder.decode(bytes) + } catch (e: Exception) { + Log.e(TAG, "AbxXmlDecoder failed: ${e.message}", e) + "" + } + } + return try { + bytes.toString(StandardCharsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "decode text xml failed: ${e.message}", e) + "" + } + } + + private fun parseTextXmlChannels(xml: String, targetPkg: String): List { + val parser = android.util.Xml.newPullParser() + parser.setInput(StringReader(xml)) + + val channelsById = LinkedHashMap() + var inTargetPkg = false + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + when (parser.name) { + "package" -> { + val pkg = parser.getAttributeValue(null, "name") ?: "" + inTargetPkg = pkg == targetPkg + } + + "channel" -> { + if (!inTargetPkg) { + event = parser.next() + continue + } + buildChannel( + id = parser.getAttributeValue(null, "id"), + name = parser.getAttributeValue(null, "name"), + description = parser.getAttributeValue(null, "desc"), + importance = parser.getAttributeValue(null, "importance"), + importanceInt = parser.getAttributeValue(null, "importance-int"), + )?.let { channel -> + channelsById.putIfAbsent(channel.id, channel) + } + } + } + } + + XmlPullParser.END_TAG -> { + if (parser.name == "package" && inTargetPkg) { + if (channelsById.isNotEmpty()) { + return channelsById.values.toList() + } + // 某些 ROM 会有多个同名 package 条目,前一个可能不带 channel,继续向后查找。 + inTargetPkg = false + } + } + } + event = parser.next() + } + + return channelsById.values.toList() + } + + private fun buildChannel( + id: String?, + name: String?, + description: String?, + importance: String?, + importanceInt: String?, + ): NotificationChannelRecord? { + val channelId = id?.takeIf { it.isNotBlank() } ?: return null + return NotificationChannelRecord( + id = channelId, + name = name ?: channelId, + description = description ?: "", + importance = (importance ?: importanceInt)?.toIntOrNull() ?: 3, + ) + } + + private fun sanitizeInvalidXml(xml: String): String { + val sanitizedEntities = Regex("""&#(x[0-9A-Fa-f]+|\d+);""").replace(xml) { match -> + val raw = match.groupValues[1] + val codePoint = if (raw.startsWith("x", ignoreCase = true)) { + raw.substring(1).toIntOrNull(16) + } else { + raw.toIntOrNull() + } + if (codePoint != null && !isValidXmlCodePoint(codePoint)) "" else match.value + } + + return buildString(sanitizedEntities.length) { + sanitizedEntities.forEach { ch -> + if (isValidXmlCodePoint(ch.code)) append(ch) + } + } + } + + private fun isValidXmlCodePoint(codePoint: Int): Boolean { + return codePoint == 0x9 || + codePoint == 0xA || + codePoint == 0xD || + codePoint in 0x20..0xD7FF || + codePoint in 0xE000..0xFFFD || + codePoint in 0x10000..0x10FFFF + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/config/ConfigIoManager.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/config/ConfigIoManager.kt new file mode 100644 index 00000000..3ba0f33d --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/config/ConfigIoManager.kt @@ -0,0 +1,81 @@ +package io.github.hyperisland.data.config + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import io.github.hyperisland.data.prefs.PrefKeys +import org.json.JSONObject +import java.io.File + +class ConfigIoManager(private val context: Context) { + private val prefs = context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + + fun exportToJson(): String { + val settings = JSONObject() + prefs.all + .filterKeys { it.startsWith("pref_") } + .forEach { (key, value) -> + when (value) { + is Boolean, is Int, is Long, is Float, is String -> settings.put(key, value) + } + } + val root = JSONObject() + .put("version", 1) + .put("settings", settings) + return root.toString(2) + } + + fun importFromJson(json: String): Int { + val root = JSONObject(json) + val settings = root.optJSONObject("settings") ?: throw IllegalArgumentException("invalid format") + val editor = prefs.edit() + var count = 0 + settings.keys().forEach { key -> + val value = settings.get(key) + when (value) { + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is Double -> editor.putFloat(key, value.toFloat()) + is String -> editor.putString(key, value) + } + count += 1 + } + editor.apply() + return count + } + + fun exportToFile(): String { + val dir = context.getExternalFilesDir(null) ?: throw IllegalStateException("no external dir") + val file = File(dir, "hyperisland_config.json") + file.writeText(exportToJson()) + return file.absolutePath + } + + fun importFromFile(): Int { + val dir = context.getExternalFilesDir(null) ?: throw IllegalStateException("no external dir") + val file = File(dir, "hyperisland_config.json") + if (!file.exists()) throw IllegalStateException("config file not found") + return importFromJson(file.readText()) + } + + fun importFromUri(uri: Uri): Int { + val text = context.contentResolver.openInputStream(uri)?.use { input -> + input.bufferedReader(Charsets.UTF_8).use { it.readText() } + } ?: throw IllegalStateException("无法读取所选文件") + return importFromJson(text) + } + + fun exportToClipboard() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("hyperisland_config", exportToJson())) + } + + fun importFromClipboard(): Int { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = clipboard.primaryClip?.getItemAt(0)?.coerceToText(context)?.toString().orEmpty() + if (text.isBlank()) throw IllegalStateException("clipboard empty") + return importFromJson(text) + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt new file mode 100644 index 00000000..ccdc8fec --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt @@ -0,0 +1,40 @@ +package io.github.hyperisland.data.prefs + +object PrefKeys { + const val PREFS_NAME = "FlutterSharedPreferences" + + const val SHOW_WELCOME = "pref_show_welcome" + const val RESUME_NOTIFICATION = "pref_resume_notification" + const val USE_HOOK_APP_ICON = "pref_use_hook_app_icon" + const val INTERACTION_HAPTICS = "pref_interaction_haptics" + const val CHECK_UPDATE_ON_LAUNCH = "pref_check_update_on_launch" + const val THEME_MODE = "pref_theme_mode" + const val LOCALE = "pref_locale" + const val ROUND_ICON = "pref_round_icon" + const val MARQUEE_FEATURE = "pref_marquee_feature" + const val MARQUEE_SPEED = "pref_marquee_speed" + const val BIG_ISLAND_MAX_WIDTH_ENABLED = "pref_big_island_max_width_enabled" + const val BIG_ISLAND_MAX_WIDTH = "pref_big_island_max_width" + const val UNLOCK_ALL_FOCUS = "pref_unlock_all_focus" + const val UNLOCK_FOCUS_AUTH = "pref_unlock_focus_auth" + const val DEFAULT_FIRST_FLOAT = "pref_default_first_float" + const val DEFAULT_ENABLE_FLOAT = "pref_default_enable_float" + const val DEFAULT_SHOW_ISLAND_ICON = "pref_default_show_island_icon" + const val DEFAULT_MARQUEE = "pref_default_marquee" + const val DEFAULT_FOCUS_NOTIF = "pref_default_focus_notif" + const val DEFAULT_PRESERVE_SMALL_ICON = "pref_default_preserve_small_icon" + const val DEFAULT_RESTORE_LOCKSCREEN = "pref_default_restore_lockscreen" + const val HIDE_DESKTOP_ICON = "pref_hide_desktop_icon" + const val APP_BLACKLIST = "pref_app_blacklist" + + const val AI_ENABLED = "pref_ai_enabled" + const val AI_URL = "pref_ai_url" + const val AI_API_KEY = "pref_ai_api_key" + const val AI_MODEL = "pref_ai_model" + const val AI_PROMPT = "pref_ai_prompt" + const val AI_PROMPT_IN_USER = "pref_ai_prompt_in_user" + const val AI_TIMEOUT = "pref_ai_timeout" + const val AI_TEMPERATURE = "pref_ai_temperature" + const val AI_MAX_TOKENS = "pref_ai_max_tokens" + const val AI_LAST_LOG_JSON = "pref_ai_last_log_json" +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt new file mode 100644 index 00000000..027ab098 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt @@ -0,0 +1,82 @@ +package io.github.hyperisland.data.prefs + +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager + +class SettingsRepository(private val context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): SettingsState { + val iconVisible = isDesktopIconVisible() + val hideDesktopIcon = !iconVisible + if (prefs.getBoolean(PrefKeys.HIDE_DESKTOP_ICON, false) != hideDesktopIcon) { + prefs.edit().putBoolean(PrefKeys.HIDE_DESKTOP_ICON, hideDesktopIcon).apply() + } + + return SettingsState( + showWelcome = prefs.getBoolean(PrefKeys.SHOW_WELCOME, true), + resumeNotification = prefs.getBoolean(PrefKeys.RESUME_NOTIFICATION, true), + useHookAppIcon = prefs.getBoolean(PrefKeys.USE_HOOK_APP_ICON, true), + interactionHaptics = prefs.getBoolean(PrefKeys.INTERACTION_HAPTICS, true), + checkUpdateOnLaunch = prefs.getBoolean(PrefKeys.CHECK_UPDATE_ON_LAUNCH, true), + themeMode = prefs.getString(PrefKeys.THEME_MODE, "system") ?: "system", + locale = prefs.getString(PrefKeys.LOCALE, null), + aiEnabled = prefs.getBoolean(PrefKeys.AI_ENABLED, false), + roundIcon = prefs.getBoolean(PrefKeys.ROUND_ICON, true), + marqueeFeature = prefs.getBoolean(PrefKeys.MARQUEE_FEATURE, false), + marqueeSpeed = prefs.getInt(PrefKeys.MARQUEE_SPEED, 100).coerceIn(20, 500), + bigIslandMaxWidthEnabled = prefs.getBoolean(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, false), + bigIslandMaxWidth = prefs.getInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, 600).coerceIn(500, 1000), + unlockAllFocus = prefs.getBoolean(PrefKeys.UNLOCK_ALL_FOCUS, false), + unlockFocusAuth = prefs.getBoolean(PrefKeys.UNLOCK_FOCUS_AUTH, false), + defaultFirstFloat = prefs.getBoolean(PrefKeys.DEFAULT_FIRST_FLOAT, false), + defaultEnableFloat = prefs.getBoolean(PrefKeys.DEFAULT_ENABLE_FLOAT, false), + defaultShowIslandIcon = prefs.getBoolean(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, true), + defaultMarquee = prefs.getBoolean(PrefKeys.DEFAULT_MARQUEE, false), + defaultFocusNotif = prefs.getBoolean(PrefKeys.DEFAULT_FOCUS_NOTIF, true), + defaultPreserveSmallIcon = prefs.getBoolean(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, false), + defaultRestoreLockscreen = prefs.getBoolean(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, false), + hideDesktopIcon = hideDesktopIcon, + ) + } + + fun setBoolean(key: String, value: Boolean) { + prefs.edit().putBoolean(key, value).apply() + } + + fun setString(key: String, value: String?) { + prefs.edit().putString(key, value).apply() + } + + fun setMarqueeSpeed(value: Int) { + prefs.edit().putInt(PrefKeys.MARQUEE_SPEED, value.coerceIn(20, 500)).apply() + } + + fun setBigIslandMaxWidth(value: Int) { + prefs.edit().putInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, value.coerceIn(500, 1000)).apply() + } + + fun setDesktopIconHidden(hidden: Boolean) { + val componentName = ComponentName(context.packageName, "${context.packageName}.MainActivityAlias") + val newState = if (hidden) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + context.packageManager.setComponentEnabledSetting( + componentName, + newState, + PackageManager.DONT_KILL_APP, + ) + prefs.edit().putBoolean(PrefKeys.HIDE_DESKTOP_ICON, hidden).apply() + } + + private fun isDesktopIconVisible(): Boolean { + val componentName = ComponentName(context.packageName, "${context.packageName}.MainActivityAlias") + val state = context.packageManager.getComponentEnabledSetting(componentName) + return state != PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt new file mode 100644 index 00000000..21cafb10 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt @@ -0,0 +1,27 @@ +package io.github.hyperisland.data.prefs + +data class SettingsState( + val showWelcome: Boolean = true, + val resumeNotification: Boolean = true, + val useHookAppIcon: Boolean = true, + val interactionHaptics: Boolean = true, + val checkUpdateOnLaunch: Boolean = true, + val themeMode: String = "system", + val locale: String? = null, + val aiEnabled: Boolean = false, + val roundIcon: Boolean = true, + val marqueeFeature: Boolean = false, + val marqueeSpeed: Int = 100, + val bigIslandMaxWidthEnabled: Boolean = false, + val bigIslandMaxWidth: Int = 600, + val unlockAllFocus: Boolean = false, + val unlockFocusAuth: Boolean = false, + val defaultFirstFloat: Boolean = false, + val defaultEnableFloat: Boolean = false, + val defaultShowIslandIcon: Boolean = true, + val defaultMarquee: Boolean = false, + val defaultFocusNotif: Boolean = true, + val defaultPreserveSmallIcon: Boolean = false, + val defaultRestoreLockscreen: Boolean = false, + val hideDesktopIcon: Boolean = false, +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt new file mode 100644 index 00000000..0e09ed7c --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -0,0 +1,1201 @@ +package io.github.hyperisland.ui + +import android.content.Context +import android.content.Intent +import android.content.ClipData +import android.content.ClipboardManager +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.Image +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.compose.ui.platform.LocalContext +import io.github.hyperisland.data.prefs.PrefKeys +import io.github.hyperisland.data.prefs.SettingsState +import io.github.hyperisland.ui.ai.AiConfigScreen +import io.github.hyperisland.ui.ai.AiConfigViewModel +import io.github.hyperisland.ui.app.AppChannelsScreen +import io.github.hyperisland.ui.app.AppChannelsViewModel +import io.github.hyperisland.ui.app.AppsScreen +import io.github.hyperisland.ui.app.AppsViewModel +import io.github.hyperisland.ui.blacklist.BlacklistScreen +import io.github.hyperisland.ui.blacklist.BlacklistViewModel +import io.github.hyperisland.ui.home.HomeUiState +import io.github.hyperisland.ui.home.HomeViewModel +import io.github.hyperisland.ui.settings.SettingsViewModel +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.basic.ArrowRight +import top.yukonga.miuix.kmp.icon.extended.All +import top.yukonga.miuix.kmp.icon.extended.AppRecording +import top.yukonga.miuix.kmp.icon.extended.Settings +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn as MiuixListPopupColumn +import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar +import top.yukonga.miuix.kmp.basic.NavigationBarItem as MiuixNavigationBarItem +import top.yukonga.miuix.kmp.basic.PopupPositionProvider as MiuixPopupPositionProvider +import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider +import top.yukonga.miuix.kmp.basic.SnackbarHost as MiuixSnackbarHost +import top.yukonga.miuix.kmp.basic.SnackbarHostState as MiuixSnackbarHostState +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch +import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar +import top.yukonga.miuix.kmp.basic.SmallTopAppBar as MiuixSmallTopAppBar +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.rememberTopAppBarState +import top.yukonga.miuix.kmp.overlay.OverlayDialog as MiuixOverlayDialog +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet as MiuixOverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayListPopup as MiuixOverlayListPopup +import top.yukonga.miuix.kmp.theme.MiuixTheme + +class ComposeMainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ), + navigationBarStyle = SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ), + ) + setContent { + MiuixTheme { + MaterialTheme { + HyperIslandComposeApp() + } + } + } + } +} + +private data class TopLevelDestination( + val route: String, + val label: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, +) + +private val HomeFilledIcon: ImageVector by lazy { + ImageVector.Builder( + name = "HomeFilledCustom", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(2.5192f, 7.82274f) + curveTo(2f, 8.77128f, 2f, 9.91549f, 2f, 12.2039f) + verticalLineTo(13.725f) + curveTo(2f, 17.6258f, 2f, 19.5763f, 3.17157f, 20.7881f) + curveTo(4.34315f, 22f, 6.22876f, 22f, 10f, 22f) + horizontalLineTo(14f) + curveTo(17.7712f, 22f, 19.6569f, 22f, 20.8284f, 20.7881f) + curveTo(22f, 19.5763f, 22f, 17.6258f, 22f, 13.725f) + verticalLineTo(12.2039f) + curveTo(22f, 9.91549f, 22f, 8.77128f, 21.4808f, 7.82274f) + curveTo(20.9616f, 6.87421f, 20.0131f, 6.28551f, 18.116f, 5.10812f) + lineTo(16.116f, 3.86687f) + curveTo(14.1106f, 2.62229f, 13.1079f, 2f, 12f, 2f) + curveTo(10.8921f, 2f, 9.88939f, 2.62229f, 7.88403f, 3.86687f) + lineTo(5.88403f, 5.10813f) + curveTo(3.98695f, 6.28551f, 3.0384f, 6.87421f, 2.5192f, 7.82274f) + close() + moveTo(9f, 17.25f) + curveTo(8.58579f, 17.25f, 8.25f, 17.5858f, 8.25f, 18f) + curveTo(8.25f, 18.4142f, 8.58579f, 18.75f, 9f, 18.75f) + horizontalLineTo(15f) + curveTo(15.4142f, 18.75f, 15.75f, 18.4142f, 15.75f, 18f) + curveTo(15.75f, 17.5858f, 15.4142f, 17.25f, 15f, 17.25f) + horizontalLineTo(9f) + close() + } + }.build() +} + +private fun mainRouteIndex(route: String?): Int = when (route) { + "home" -> 0 + "apps" -> 1 + "settings" -> 2 + else -> -1 +} + +private const val DOCUMENTATION_URL = "https://hyperisland.1812z.top/" +private const val GITHUB_REPO_URL = "https://github.com/1812z/HyperIsland" +private const val GITHUB_RELEASE_URL = "https://github.com/1812z/HyperIsland/releases/latest" +private const val QQ_GROUP_NUMBER = "1045114341" + +private fun routeTitle(route: String?): String { + return when { + route == "home" -> "主页" + route == "apps" -> "应用" + route == "settings" -> "设置" + route?.startsWith("app_channels/") == true -> "渠道设置" + route == "blacklist" -> "通知黑名单" + route == "ai_config" -> "AI 配置" + else -> "HyperIsland" + } +} + +private fun routeLargeTitle(route: String?): String { + return when { + route == "home" -> "主页" + route == "apps" -> "应用适配" + route == "settings" -> "系统设置" + route?.startsWith("app_channels/") == true -> "通知渠道" + route == "blacklist" -> "通知黑名单" + route == "ai_config" -> "AI 配置" + else -> "HyperIsland" + } +} + +private fun openExternalUrl(context: Context, url: String) { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} + +@Composable +private fun HyperIslandComposeApp() { + val navController = rememberNavController() + val snackbarHostState = remember { MiuixSnackbarHostState() } + val context = LocalContext.current + val homeVm: HomeViewModel = viewModel() + val appsVm: AppsViewModel = viewModel() + val appsState by appsVm.uiState.collectAsStateWithLifecycle() + var showRestartDialog by remember { mutableStateOf(false) } + var showSponsorDialog by remember { mutableStateOf(false) } + var showAppsMenu by remember { mutableStateOf(false) } + var appsBatchRequestId by remember { mutableStateOf(0) } + + val items = listOf( + TopLevelDestination("home", "主页", HomeFilledIcon), + TopLevelDestination("apps", "应用", MiuixIcons.Regular.All), + TopLevelDestination("settings", "设置", MiuixIcons.Regular.Settings), + ) + + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val isSecondaryRoute = currentRoute?.startsWith("app_channels/") == true || + currentRoute == "blacklist" || + currentRoute == "ai_config" + val scrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { true }, + ) + + MiuixScaffold( + snackbarHost = { MiuixSnackbarHost(snackbarHostState) }, + topBar = { + if (isSecondaryRoute) { + MiuixSmallTopAppBar( + title = routeTitle(currentRoute), + navigationIcon = { + MiuixIconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = "返回", + modifier = Modifier.rotate(180f), + ) + } + }, + modifier = Modifier, + scrollBehavior = null, + defaultWindowInsetsPadding = false, + ) + } else { + MiuixTopAppBar( + title = routeTitle(currentRoute), + largeTitle = routeLargeTitle(currentRoute), + actions = { + if (currentRoute == "home") { + MiuixIconButton(onClick = { openExternalUrl(context, DOCUMENTATION_URL) }) { + Icon( + imageVector = Icons.Filled.InsertDriveFile, + contentDescription = "文档", + ) + } + MiuixIconButton(onClick = { showSponsorDialog = true }) { + Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = "赞助", + ) + } + MiuixIconButton(onClick = { showRestartDialog = true }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "重启作用域", + ) + } + } else if (currentRoute == "apps") { + Box { + MiuixIconButton(onClick = { showAppsMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "更多操作", + ) + } + MiuixOverlayListPopup( + show = showAppsMenu, + alignment = MiuixPopupPositionProvider.Align.Start, + onDismissRequest = { showAppsMenu = false }, + onDismissFinished = {}, + ) { + MiuixListPopupColumn { + OverlayListPopupMenuItem(if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") { + showAppsMenu = false + appsVm.setShowSystemApps(!appsState.showSystemApps) + } + OverlayListPopupMenuItem("全局批量应用") { + showAppsMenu = false + appsBatchRequestId += 1 + } + OverlayListPopupMenuItem("刷新") { + showAppsMenu = false + appsVm.refresh() + } + } + } + } + } + }, + modifier = Modifier, + scrollBehavior = scrollBehavior, + defaultWindowInsetsPadding = false, + ) + } + }, + bottomBar = { + if (!isSecondaryRoute) { + val selectedIndex = items.indexOfFirst { it.route == currentRoute }.coerceAtLeast(0) + MiuixNavigationBar( + modifier = Modifier.height(62.dp), + showDivider = false, + defaultWindowInsetsPadding = false, + ) { + items.forEachIndexed { index, destination -> + MiuixNavigationBarItem( + selected = index == selectedIndex, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = destination.icon, + label = destination.label, + ) + } + } + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = "home", + enterTransition = { + val from = mainRouteIndex(initialState.destination.route) + val to = mainRouteIndex(targetState.destination.route) + if (from >= 0 && to >= 0 && from != to) { + if (to > from) { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(260), + ) + } else { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(260), + ) + } + } else { + EnterTransition.None + } + }, + exitTransition = { + val from = mainRouteIndex(initialState.destination.route) + val to = mainRouteIndex(targetState.destination.route) + if (from >= 0 && to >= 0 && from != to) { + if (to > from) { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(260), + ) + } else { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(260), + ) + } + } else { + ExitTransition.None + } + }, + popEnterTransition = { + val from = mainRouteIndex(initialState.destination.route) + val to = mainRouteIndex(targetState.destination.route) + if (from >= 0 && to >= 0 && from != to) { + if (to > from) { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(260), + ) + } else { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(260), + ) + } + } else { + EnterTransition.None + } + }, + popExitTransition = { + val from = mainRouteIndex(initialState.destination.route) + val to = mainRouteIndex(targetState.destination.route) + if (from >= 0 && to >= 0 && from != to) { + if (to > from) { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(260), + ) + } else { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(260), + ) + } + } else { + ExitTransition.None + } + }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + composable("home") { + val uiState by homeVm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + homeVm.events.collect { snackbarHostState.showSnackbar(it) } + } + HomeScreen( + uiState = uiState, + onRefresh = homeVm::refreshStatus, + onSendTest = homeVm::sendTest, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) + } + composable("apps") { + LaunchedEffect(Unit) { + appsVm.events.collect { snackbarHostState.showSnackbar(it) } + } + AppsScreen( + state = appsState, + onRefresh = appsVm::refresh, + onQueryChange = appsVm::setQuery, + onAppEnabledChange = appsVm::setEnabled, + onOpenAppChannels = { pkg -> navController.navigate("app_channels/$pkg") }, + onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, + batchRequestId = appsBatchRequestId, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) + } + composable("settings") { + val vm: SettingsViewModel = viewModel() + val uiState by vm.uiState.collectAsStateWithLifecycle() + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + vm.importConfigFromUri(uri) + } + } + LaunchedEffect(Unit) { + vm.events.collect { snackbarHostState.showSnackbar(it) } + } + SettingsScreen( + state = uiState, + onToggle = vm::updateSwitch, + onMarqueeSpeed = vm::updateMarqueeSpeed, + onBigIslandWidth = vm::updateBigIslandMaxWidth, + onThemeModeChange = vm::updateThemeMode, + onLocaleChange = vm::updateLocale, + onHideDesktopIcon = vm::setDesktopIconHidden, + onOpenBlacklist = { navController.navigate("blacklist") }, + onOpenAiConfig = { navController.navigate("ai_config") }, + onCheckUpdate = { openExternalUrl(context, GITHUB_RELEASE_URL) }, + onOpenGithub = { openExternalUrl(context, GITHUB_REPO_URL) }, + onExportToFile = vm::exportConfigToFile, + onPickImportFile = { importLauncher.launch(arrayOf("application/json", "text/plain")) }, + onExportToClipboard = vm::exportConfigToClipboard, + onImportFromClipboard = vm::importConfigFromClipboard, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) + } + composable( + route = "app_channels/{packageName}", + arguments = listOf(navArgument("packageName") { type = NavType.StringType }), + ) { backStack -> + val vm: AppChannelsViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + val packageNameArg = backStack.arguments?.getString("packageName").orEmpty() + LaunchedEffect(packageNameArg) { + vm.setPackageNameIfEmpty(packageNameArg) + } + AppChannelsScreen( + state = state, + onRefresh = vm::refresh, + onToggleChannel = vm::toggleChannel, + onEnableAllChannels = vm::enableAllChannels, + onCycleTemplate = vm::cycleTemplate, + onSetTimeout = vm::setTimeout, + onCycleSetting = vm::cycleSetting, + onSetHighlightColor = vm::setHighlightColor, + onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, + ) + } + composable("blacklist") { + val vm: BlacklistViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.events.collect { snackbarHostState.showSnackbar(it) } + } + BlacklistScreen( + state = state, + onRefresh = vm::refresh, + onQueryChange = vm::setQuery, + onShowSystemChange = vm::setShowSystemApps, + onSetBlacklisted = vm::setBlacklisted, + onEnableAllVisible = vm::enableAllVisible, + onDisableAllVisible = vm::disableAllVisible, + onApplyGamePreset = vm::applyGamePreset, + ) + } + composable("ai_config") { + val vm: AiConfigViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.events.collect { snackbarHostState.showSnackbar(it) } + } + AiConfigScreen( + state = state, + onUpdate = vm::setState, + onSave = vm::save, + onTest = vm::testConnection, + ) + } + } + if (showSponsorDialog) { + SponsorDialog( + show = true, + onDismiss = { showSponsorDialog = false }, + ) + } + if (showRestartDialog) { + RestartScopeDialog( + show = true, + onDismiss = { showRestartDialog = false }, + onConfirm = { systemUi, downloads, xmsf -> + showRestartDialog = false + homeVm.restartScopes(systemUi, downloads, xmsf) + }, + ) + } + } +} + +@Composable +private fun HomeScreen( + uiState: HomeUiState, + onRefresh: () -> Unit, + onSendTest: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("模块状态", style = MaterialTheme.typography.titleMedium) + val statusText = when (uiState.moduleActive) { + null -> "检测中..." + true -> "已激活" + false -> "未激活" + } + Text("LSPosed API: ${uiState.lsposedApiVersion}") + Text("模块状态: $statusText") + Text("Focus 协议版本: ${uiState.focusProtocolVersion}") + } + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + MiuixButton(onClick = onRefresh, modifier = Modifier.weight(1f)) { + Text("刷新状态") + } + MiuixButton(onClick = onSendTest, modifier = Modifier.weight(1f)) { + Text("发送测试通知") + } + } + } + } +} + +@Composable +private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { + val context = LocalContext.current + val qrBitmap = remember { + runCatching { + context.assets.open("flutter_assets/assets/images/wechat.jpg").use { stream -> + BitmapFactory.decodeStream(stream) + } + }.getOrNull() + } + MiuixOverlayDialog( + show = show, + title = "赞助支持", + summary = "", + onDismissRequest = onDismiss, + onDismissFinished = {}, + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("赞助作者") + if (qrBitmap != null) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "微信赞助二维码", + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text("未找到赞助图片 assets/images/wechat.jpg") + } + MiuixButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("关闭") + } + } + } +} + +@Composable +private fun RestartScopeDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: (Boolean, Boolean, Boolean) -> Unit, +) { + var restartSystemUi by remember { mutableStateOf(true) } + var restartDownloads by remember { mutableStateOf(true) } + var restartXmsf by remember { mutableStateOf(true) } + + MiuixOverlayBottomSheet( + show = show, + title = "选择需要重启的进程", + onDismissRequest = onDismiss, + onDismissFinished = {}, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + ScopeCheckboxRow("SystemUI(com.android.systemui)", restartSystemUi) { restartSystemUi = !restartSystemUi } + ScopeCheckboxRow("下载管理(com.android.providers.downloads)", restartDownloads) { restartDownloads = !restartDownloads } + ScopeCheckboxRow("XMSF(com.xiaomi.xmsf)", restartXmsf) { restartXmsf = !restartXmsf } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text("取消") + } + MiuixButton( + onClick = { onConfirm(restartSystemUi, restartDownloads, restartXmsf) }, + modifier = Modifier.weight(1f), + ) { + Text("重启") + } + } + } + } +} + +@Composable +private fun ScopeCheckboxRow(label: String, checked: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, modifier = Modifier.weight(1f)) + MiuixCheckbox( + state = if (checked) ToggleableState.On else ToggleableState.Off, + onClick = onClick, + ) + } +} + +@Composable +private fun SettingsScreen( + state: SettingsState, + onToggle: (String, Boolean) -> Unit, + onMarqueeSpeed: (Int) -> Unit, + onBigIslandWidth: (Int) -> Unit, + onThemeModeChange: (String) -> Unit, + onLocaleChange: (String?) -> Unit, + onHideDesktopIcon: (Boolean) -> Unit, + onOpenBlacklist: () -> Unit, + onOpenAiConfig: () -> Unit, + onCheckUpdate: () -> Unit, + onOpenGithub: () -> Unit, + onExportToFile: () -> Unit, + onPickImportFile: () -> Unit, + onExportToClipboard: () -> Unit, + onImportFromClipboard: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var showThemeDialog by remember { mutableStateOf(false) } + var showLanguageDialog by remember { mutableStateOf(false) } + + if (showThemeDialog) { + MiuixOverlayDialog( + show = true, + title = "颜色模式", + summary = "", + onDismissRequest = { showThemeDialog = false }, + onDismissFinished = {}, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeOption("跟随系统", state.themeMode == "system") { + onThemeModeChange("system") + showThemeDialog = false + } + ThemeOption("浅色", state.themeMode == "light") { + onThemeModeChange("light") + showThemeDialog = false + } + ThemeOption("深色", state.themeMode == "dark") { + onThemeModeChange("dark") + showThemeDialog = false + } + } + } + } + + if (showLanguageDialog) { + MiuixOverlayDialog( + show = true, + title = "语言", + summary = "", + onDismissRequest = { showLanguageDialog = false }, + onDismissFinished = {}, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeOption("跟随系统", state.locale == null) { + onLocaleChange(null) + showLanguageDialog = false + } + ThemeOption("中文", state.locale == "zh") { + onLocaleChange("zh") + showLanguageDialog = false + } + ThemeOption("English", state.locale == "en") { + onLocaleChange("en") + showLanguageDialog = false + } + ThemeOption("日本語", state.locale == "ja") { + onLocaleChange("ja") + showLanguageDialog = false + } + ThemeOption("Türkçe", state.locale == "tr") { + onLocaleChange("tr") + showLanguageDialog = false + } + } + } + } + + val themeModeText = when (state.themeMode) { + "light" -> "浅色" + "dark" -> "深色" + else -> "跟随系统" + } + val languageText = when (state.locale) { + "zh" -> "中文" + "en" -> "English" + "ja" -> "日本語" + "tr" -> "Türkçe" + else -> "跟随系统" + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { SectionTitle("AI 增强") } + item { + SettingsEntryItem( + title = "AI 通知摘要", + subtitle = if (state.aiEnabled) "已启用 · 点击配置 AI 参数" else "已关闭 · 点击进行配置", + onClick = onOpenAiConfig, + ) + } + + item { SectionTitle("通知黑名单") } + item { + SettingsEntryItem( + title = "通知黑名单", + subtitle = "启动黑名单应用时,停用焦点通知的自动展开功能", + onClick = onOpenBlacklist, + ) + } + + item { SectionTitle("行为") } + item { + ToggleItem( + "交互触感", + "为开关、滑块和按钮启用 Hyper 定制震感反馈", + state.interactionHaptics, + ) { onToggle(PrefKeys.INTERACTION_HAPTICS, it) } + } + item { + ToggleItem( + "下载管理器暂停后保留焦点通知", + "显示一条通知,点击以继续下载,可能导致状态不同步", + state.resumeNotification, + ) { onToggle(PrefKeys.RESUME_NOTIFICATION, it) } + } + item { + ToggleItem( + "移除焦点通知白名单", + "允许所有应用发送焦点通知,无需系统授权", + state.unlockAllFocus, + ) { onToggle(PrefKeys.UNLOCK_ALL_FOCUS, it) } + } + item { + ToggleItem( + "移除焦点通知签名验证", + "允许所有应用向手表/手环发送焦点通知,跳过签名校验(需 Hook 小米服务框架)", + state.unlockFocusAuth, + ) { onToggle(PrefKeys.UNLOCK_FOCUS_AUTH, it) } + } + item { + ToggleItem( + "显示启动欢迎语", + "应用启动时在超级岛显示欢迎信息", + state.showWelcome, + ) { onToggle(PrefKeys.SHOW_WELCOME, it) } + } + item { + ToggleItem( + "隐藏桌面图标", + "隐藏启动器中的应用图标,隐藏后可通过 LSPosed 管理器打开", + state.hideDesktopIcon, + onHideDesktopIcon, + ) + } + item { + ToggleItem( + "启动时检查更新", + "启动应用时自动检查是否有新版本", + state.checkUpdateOnLaunch, + ) { onToggle(PrefKeys.CHECK_UPDATE_ON_LAUNCH, it) } + } + + item { SectionTitle("渠道默认配置") } + item { + ToggleItem( + "初次展开", + "超级岛初次收到通知后是否展开为焦点通知", + state.defaultFirstFloat, + ) { onToggle(PrefKeys.DEFAULT_FIRST_FLOAT, it) } + } + item { + ToggleItem( + "更新展开", + "超级岛更新后是否展开通知", + state.defaultEnableFloat, + ) { onToggle(PrefKeys.DEFAULT_ENABLE_FLOAT, it) } + } + item { + ToggleItem( + "消息滚动", + "超级岛消息过长是否滚动显示", + state.defaultMarquee, + ) { onToggle(PrefKeys.DEFAULT_MARQUEE, it) } + } + item { + ToggleItem( + "焦点通知", + "替换通知为焦点通知(关闭后显示原始通知)", + state.defaultFocusNotif, + ) { onToggle(PrefKeys.DEFAULT_FOCUS_NOTIF, it) } + } + item { + ToggleItem( + "锁屏通知复原", + "锁屏时跳过焦点通知处理,保持原始通知隐私行为", + state.defaultRestoreLockscreen, + ) { onToggle(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, it) } + } + item { + ToggleItem( + "大岛图标", + "开启后显示超级岛的大图标(小岛不受影响)", + state.defaultShowIslandIcon, + ) { onToggle(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, it) } + } + item { + ToggleItem( + "状态栏图标", + "焦点通知打开时,是否强制保留状态栏小图标", + state.defaultPreserveSmallIcon, + ) { onToggle(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, it) } + } + + item { SectionTitle("外观") } + item { + ToggleItem( + "使用应用图标", + "下载管理器通知使用应用图标", + state.useHookAppIcon, + ) { onToggle(PrefKeys.USE_HOOK_APP_ICON, it) } + } + item { + ToggleItem( + "图标圆角", + "为通知图标添加圆角效果", + state.roundIcon, + ) { onToggle(PrefKeys.ROUND_ICON, it) } + } + item { + SliderItem( + title = "消息滚动", + subtitle = "滚动速度", + valueText = "${state.marqueeSpeed} 像素/秒", + value = state.marqueeSpeed.toFloat(), + valueRange = 20f..500f, + steps = 48, + onValueChange = { onMarqueeSpeed(it.toInt()) }, + ) + } + item { + ToggleSliderItem( + "修改超级岛最大宽度", + "开启后修改超级岛的最大宽度", + state.bigIslandMaxWidthEnabled, + valueText = "${state.bigIslandMaxWidth} dp", + value = state.bigIslandMaxWidth.toFloat(), + valueRange = 500f..1000f, + steps = 54, + onCheckedChange = { onToggle(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, it) }, + onValueChange = { onBigIslandWidth(it.toInt()) }, + ) + } + item { + SettingsEntryItem( + title = "颜色模式", + subtitle = themeModeText, + onClick = { showThemeDialog = true }, + ) + } + item { + SettingsEntryItem( + title = "语言", + subtitle = languageText, + onClick = { showLanguageDialog = true }, + ) + } + + item { SectionTitle("配置") } + item { SettingsEntryItem("导出到文件", "将配置保存为 JSON 文件", onExportToFile) } + item { SettingsEntryItem("导出到剪贴板", "将配置复制为 JSON 文本", onExportToClipboard) } + item { SettingsEntryItem("从文件导入", "从 JSON 文件恢复配置", onPickImportFile) } + item { SettingsEntryItem("从剪贴板导入", "从剪贴板中的 JSON 文本恢复配置", onImportFromClipboard) } + + item { SectionTitle("关于") } + item { SettingsEntryItem("检查更新", "", onCheckUpdate) } + item { SettingsEntryItem("GitHub", "1812z/HyperIsland", onOpenGithub) } + item { + SettingsEntryItem( + title = "QQ 交流群", + subtitle = QQ_GROUP_NUMBER, + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("qq_group", QQ_GROUP_NUMBER)) + Toast.makeText(context, "群号已复制到剪贴板", Toast.LENGTH_SHORT).show() + }, + ) + } + } +} + +@Composable +private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Unit) { + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = null, + ) + } + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) +} + +@Composable +private fun ToggleItem( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title) + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) + } + } +} + +@Composable +private fun SliderItem( + title: String, + subtitle: String, + valueText: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit, +) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(valueText) + } + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(8.dp)) + } + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + ) + } + } +} + +@Composable +private fun ToggleSliderItem( + title: String, + subtitle: String, + checked: Boolean, + valueText: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + onCheckedChange: (Boolean) -> Unit, + onValueChange: (Float) -> Unit, +) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title) + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) + } + if (checked) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier.weight(1f), + ) + Text(valueText) + } + } + } + } +} + +@Composable +private fun ThemeOption(title: String, selected: Boolean, onClick: () -> Unit) { + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title) + if (selected) { + Text("已选择", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun OverlayListPopupMenuItem(title: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigRepository.kt new file mode 100644 index 00000000..348a34b4 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigRepository.kt @@ -0,0 +1,42 @@ +package io.github.hyperisland.ui.ai + +import android.content.Context +import io.github.hyperisland.data.prefs.PrefKeys + +class AiConfigRepository(context: Context) { + private val prefs = context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): AiConfigState { + return AiConfigState( + enabled = prefs.getBoolean(PrefKeys.AI_ENABLED, false), + url = prefs.getString(PrefKeys.AI_URL, "") ?: "", + apiKey = prefs.getString(PrefKeys.AI_API_KEY, "") ?: "", + model = prefs.getString(PrefKeys.AI_MODEL, "") ?: "", + prompt = prefs.getString(PrefKeys.AI_PROMPT, "") + ?.takeIf { it.isNotBlank() } + ?: "根据通知信息,提取关键信息,左右分别不超过6汉字12字符", + promptInUser = prefs.getBoolean(PrefKeys.AI_PROMPT_IN_USER, false), + timeout = prefs.getInt(PrefKeys.AI_TIMEOUT, 3).coerceIn(3, 15), + temperature = prefs.getFloat(PrefKeys.AI_TEMPERATURE, 0.1f).toDouble().coerceIn(0.0, 1.0), + maxTokens = prefs.getInt(PrefKeys.AI_MAX_TOKENS, 50).coerceIn(10, 500), + ) + } + + fun save(state: AiConfigState) { + prefs.edit() + .putBoolean(PrefKeys.AI_ENABLED, state.enabled) + .putString(PrefKeys.AI_URL, state.url) + .putString(PrefKeys.AI_API_KEY, state.apiKey) + .putString(PrefKeys.AI_MODEL, state.model) + .putString(PrefKeys.AI_PROMPT, state.prompt) + .putBoolean(PrefKeys.AI_PROMPT_IN_USER, state.promptInUser) + .putInt(PrefKeys.AI_TIMEOUT, state.timeout.coerceIn(3, 15)) + .putFloat(PrefKeys.AI_TEMPERATURE, state.temperature.toFloat().coerceIn(0f, 1f)) + .putInt(PrefKeys.AI_MAX_TOKENS, state.maxTokens.coerceIn(10, 500)) + .apply() + } + + fun saveLastLogJson(value: String) { + prefs.edit().putString(PrefKeys.AI_LAST_LOG_JSON, value).apply() + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt new file mode 100644 index 00000000..281b2b48 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt @@ -0,0 +1,233 @@ +package io.github.hyperisland.ui.ai + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField + +@Composable +fun AiConfigScreen( + state: AiConfigState, + onUpdate: (AiConfigState) -> Unit, + onSave: () -> Unit, + onTest: () -> Unit, +) { + var keyObscured by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("AI 增强", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text("启用 AI 摘要") + Spacer(modifier = Modifier.height(4.dp)) + Text("由 AI 生成超级岛左右文本,超时或失败时自动回退", style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = state.enabled, + onCheckedChange = { onUpdate(state.copy(enabled = it)) }, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + + Text("API 参数", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + MiuixTextField( + value = state.url, + onValueChange = { onUpdate(state.copy(url = it)) }, + label = "API 地址(必须完整)", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + MiuixTextField( + value = state.apiKey, + onValueChange = { onUpdate(state.copy(apiKey = it)) }, + label = "API 密钥", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (keyObscured) PasswordVisualTransformation() else VisualTransformation.None, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + MiuixButton(onClick = { keyObscured = !keyObscured }) { + Text(if (keyObscured) "显示密钥" else "隐藏密钥") + } + } + MiuixTextField( + value = state.model, + onValueChange = { onUpdate(state.copy(model = it)) }, + label = "模型", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + MiuixTextField( + value = state.prompt, + onValueChange = { onUpdate(state.copy(prompt = it)) }, + label = "系统提示词", + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 8, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text("提示词放在用户消息") + Spacer(modifier = Modifier.height(4.dp)) + Text("某些模型不支持系统指令,开启后将提示词放在用户消息中", style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = state.promptInUser, + onCheckedChange = { onUpdate(state.copy(promptInUser = it)) }, + ) + } + + SliderItem( + title = "AI 响应超时", + subtitle = "", + valueText = "${state.timeout}s", + value = state.timeout.toFloat(), + range = 3f..15f, + steps = 11, + onValueChange = { onUpdate(state.copy(timeout = it.toInt().coerceIn(3, 15))) }, + ) + SliderItem( + title = "采样温度 (Temperature)", + subtitle = "控制回答的随机性。0 为准确,1 则更具创意", + valueText = String.format("%.1f", state.temperature), + value = state.temperature.toFloat(), + range = 0f..1f, + steps = 10, + onValueChange = { onUpdate(state.copy(temperature = it.toDouble().coerceIn(0.0, 1.0))) }, + ) + SliderItem( + title = "最大 Token 数 (Max Tokens)", + subtitle = "限制 AI 生成回答的最大长度", + valueText = state.maxTokens.toString(), + value = state.maxTokens.toFloat(), + range = 20f..100f, + steps = 80, + onValueChange = { onUpdate(state.copy(maxTokens = it.toInt().coerceIn(20, 100))) }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + MiuixButton(onClick = onTest, enabled = !state.testing, modifier = Modifier.weight(1f)) { + Text(if (state.testing) "测试中..." else "测试连接") + } + MiuixButton(onClick = onSave, modifier = Modifier.weight(1f)) { + Text("保存") + } + } + + state.testResult?.let { + TestResultCard(text = it) + } + } + } + + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", + style = MaterialTheme.typography.bodySmall, + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + } +} + +@Composable +private fun SliderItem( + title: String, + subtitle: String, + valueText: String, + value: Float, + range: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f).padding(end = 10.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + if (subtitle.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + } + Text(valueText, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + } + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = range, + steps = steps, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun TestResultCard(text: String) { + val isSuccess = text.isNotBlank() && !text.startsWith("HTTP ") && !text.contains("Exception") + val bg = if (isSuccess) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + } + val fg = if (isSuccess) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(bg, shape = MaterialTheme.shapes.medium) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(if (isSuccess) "测试结果(成功)" else "测试结果(失败)", color = fg, fontWeight = FontWeight.SemiBold) + Text(text, color = fg, style = MaterialTheme.typography.bodySmall) + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigState.kt new file mode 100644 index 00000000..18b192eb --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigState.kt @@ -0,0 +1,15 @@ +package io.github.hyperisland.ui.ai + +data class AiConfigState( + val enabled: Boolean = false, + val url: String = "", + val apiKey: String = "", + val model: String = "", + val prompt: String = "根据通知信息,提取关键信息,左右分别不超过6汉字12字符", + val promptInUser: Boolean = false, + val timeout: Int = 3, + val temperature: Double = 0.1, + val maxTokens: Int = 50, + val testing: Boolean = false, + val testResult: String? = null, +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigViewModel.kt new file mode 100644 index 00000000..5c04f1c4 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigViewModel.kt @@ -0,0 +1,130 @@ +package io.github.hyperisland.ui.ai + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class AiConfigViewModel(app: Application) : AndroidViewModel(app) { + private val repo = AiConfigRepository(app) + + private val _uiState = MutableStateFlow(repo.load()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun update(block: (AiConfigState) -> AiConfigState) { + _uiState.update(block) + } + + fun setState(state: AiConfigState) { + _uiState.value = state + } + + fun save() { + repo.save(_uiState.value) + viewModelScope.launch { _events.emit("AI 配置已保存") } + } + + fun testConnection() { + val state = _uiState.value + if (state.url.isBlank()) { + viewModelScope.launch { _events.emit("请先填写 AI URL") } + return + } + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(testing = true, testResult = null) } + val reqBody = buildRequestBody(state) + val result = runCatching { + val conn = (URL(state.url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + if (state.apiKey.isNotBlank()) { + setRequestProperty("Authorization", "Bearer ${state.apiKey}") + } + connectTimeout = state.timeout * 1000 + readTimeout = state.timeout * 1000 + doOutput = true + } + conn.outputStream.use { it.write(reqBody.toByteArray(Charsets.UTF_8)) } + val code = conn.responseCode + val body = if (code in 200..299) { + conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } else { + conn.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: "" + } + conn.disconnect() + code to body + } + + if (result.isSuccess) { + val (code, body) = result.getOrThrow() + val preview = if (code == 200) { + parseResponsePreview(body) + } else { + "HTTP $code\n$body" + } + repo.saveLastLogJson( + JSONObject() + .put("timestamp", java.time.Instant.now().toString()) + .put("source", "compose_test") + .put("url", state.url) + .put("model", state.model.ifBlank { "gpt-4o-mini" }) + .put("requestBody", reqBody) + .put("responseBody", body) + .put("error", if (code == 200) "" else "HTTP $code") + .put("statusCode", code) + .toString(), + ) + _uiState.update { it.copy(testing = false, testResult = preview) } + } else { + val msg = result.exceptionOrNull()?.toString() ?: "请求失败" + _uiState.update { it.copy(testing = false, testResult = msg) } + } + } + } + + private fun buildRequestBody(state: AiConfigState): String { + val userContent = "应用包名:com.example.app\\n标题:测试通知\\n正文:这是一条用于测试 AI 提取效果的示例消息" + val messages = JSONArray() + if (state.promptInUser) { + messages.put(JSONObject().put("role", "user").put("content", state.prompt)) + } else { + messages.put(JSONObject().put("role", "system").put("content", state.prompt)) + } + messages.put(JSONObject().put("role", "user").put("content", userContent)) + + return JSONObject() + .put("model", state.model.ifBlank { "gpt-4o-mini" }) + .put("messages", messages) + .put("max_tokens", state.maxTokens) + .put("temperature", state.temperature) + .toString() + } + + private fun parseResponsePreview(body: String): String { + return runCatching { + val root = JSONObject(body) + root.getJSONArray("choices") + .getJSONObject(0) + .getJSONObject("message") + .getString("content") + .trim() + }.getOrElse { body } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt new file mode 100644 index 00000000..71a5ee1f --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt @@ -0,0 +1,197 @@ +package io.github.hyperisland.ui.app + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ApplicationInfo +import io.github.hyperisland.NotificationChannelReader +import io.github.hyperisland.data.prefs.PrefKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AppAdaptationRepository(private val context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + + suspend fun loadInstalledApps(): List = withContext(Dispatchers.IO) { + val pm = context.packageManager + pm.getInstalledApplications(0) + .asSequence() + .filter { it.packageName != context.packageName } + .mapNotNull { app -> + runCatching { + AppItem( + packageName = app.packageName, + appName = pm.getApplicationLabel(app).toString(), + isSystem = (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0, + ) + }.getOrNull() + } + .sortedBy { it.appName.lowercase() } + .toList() + } + + fun loadEnabledPackages(): Set { + val csv = prefs.getString("pref_generic_whitelist", "") ?: "" + return if (csv.isBlank()) emptySet() else csv.split(",").filter { it.isNotBlank() }.toSet() + } + + fun setEnabledPackages(value: Set) { + prefs.edit().putString("pref_generic_whitelist", value.joinToString(",")).apply() + } + + suspend fun loadChannels(packageName: String): List? = withContext(Dispatchers.IO) { + NotificationChannelReader.readChannels(packageName)?.map { + ChannelItem( + id = it.id, + name = it.name, + description = it.description, + importance = it.importance, + ) + } + } + + fun getEnabledChannels(packageName: String): Set { + val csv = prefs.getString("pref_channels_$packageName", "") ?: "" + return if (csv.isBlank()) emptySet() else csv.split(",").filter { it.isNotBlank() }.toSet() + } + + fun setEnabledChannels(packageName: String, channels: Set) { + prefs.edit().putString("pref_channels_$packageName", channels.joinToString(",")).apply() + } + + fun getChannelTemplate(packageName: String, channelId: String): String { + return prefs.getString( + "pref_channel_template_${packageName}_$channelId", + "notification_island", + ) ?: "notification_island" + } + + fun setChannelTemplate(packageName: String, channelId: String, template: String) { + prefs.edit().putString("pref_channel_template_${packageName}_$channelId", template).apply() + } + + fun getChannelTimeout(packageName: String, channelId: String): String { + return prefs.getString("pref_channel_timeout_${packageName}_$channelId", "5") ?: "5" + } + + fun setChannelTimeout(packageName: String, channelId: String, value: String) { + prefs.edit().putString("pref_channel_timeout_${packageName}_$channelId", value).apply() + } + + fun getChannelExtras(packageName: String, channelId: String): ChannelExtraSettings { + return ChannelExtraSettings( + icon = prefs.getString("pref_channel_icon_${packageName}_$channelId", "auto") ?: "auto", + focusIcon = prefs.getString("pref_channel_focus_icon_${packageName}_$channelId", "auto") ?: "auto", + focus = prefs.getString("pref_channel_focus_${packageName}_$channelId", "default") ?: "default", + preserveSmallIcon = prefs.getString( + "pref_channel_preserve_small_icon_${packageName}_$channelId", + "default", + ) ?: "default", + showIslandIcon = prefs.getString( + "pref_channel_show_island_icon_${packageName}_$channelId", + "default", + ) ?: "default", + firstFloat = prefs.getString("pref_channel_first_float_${packageName}_$channelId", "default") + ?: "default", + enableFloat = prefs.getString("pref_channel_enable_float_${packageName}_$channelId", "default") + ?: "default", + marquee = prefs.getString("pref_channel_marquee_${packageName}_$channelId", "default") ?: "default", + renderer = prefs.getString( + "pref_channel_renderer_${packageName}_$channelId", + "image_text_with_buttons_4", + ) ?: "image_text_with_buttons_4", + restoreLockscreen = prefs.getString( + "pref_channel_restore_lockscreen_${packageName}_$channelId", + "default", + ) ?: "default", + highlightColor = prefs.getString("pref_channel_highlight_color_${packageName}_$channelId", "") ?: "", + showLeftHighlight = prefs.getString( + "pref_channel_show_left_highlight_${packageName}_$channelId", + "off", + ) ?: "off", + showRightHighlight = prefs.getString( + "pref_channel_show_right_highlight_${packageName}_$channelId", + "off", + ) ?: "off", + ) + } + + fun setChannelSetting(packageName: String, channelId: String, setting: String, value: String) { + val key = when (setting) { + "icon" -> "pref_channel_icon_${packageName}_$channelId" + "focus_icon" -> "pref_channel_focus_icon_${packageName}_$channelId" + "focus" -> "pref_channel_focus_${packageName}_$channelId" + "preserve_small_icon" -> "pref_channel_preserve_small_icon_${packageName}_$channelId" + "show_island_icon" -> "pref_channel_show_island_icon_${packageName}_$channelId" + "first_float" -> "pref_channel_first_float_${packageName}_$channelId" + "enable_float" -> "pref_channel_enable_float_${packageName}_$channelId" + "marquee" -> "pref_channel_marquee_${packageName}_$channelId" + "renderer" -> "pref_channel_renderer_${packageName}_$channelId" + "restore_lockscreen" -> "pref_channel_restore_lockscreen_${packageName}_$channelId" + "highlight_color" -> "pref_channel_highlight_color_${packageName}_$channelId" + "show_left_highlight" -> "pref_channel_show_left_highlight_${packageName}_$channelId" + "show_right_highlight" -> "pref_channel_show_right_highlight_${packageName}_$channelId" + else -> return + } + if (setting == "highlight_color" && value.isBlank()) { + prefs.edit().remove(key).apply() + } else { + prefs.edit().putString(key, value).apply() + } + } + + fun batchApplyChannelSettings( + packageName: String, + channelIds: List, + settings: Map, + ) { + if (channelIds.isEmpty() || settings.isEmpty()) return + val editor = prefs.edit() + for (channelId in channelIds) { + settings.forEach { (setting, value) -> + val key = when (setting) { + "template" -> "pref_channel_template_${packageName}_$channelId" + "timeout" -> "pref_channel_timeout_${packageName}_$channelId" + "icon" -> "pref_channel_icon_${packageName}_$channelId" + "focus_icon" -> "pref_channel_focus_icon_${packageName}_$channelId" + "focus" -> "pref_channel_focus_${packageName}_$channelId" + "preserve_small_icon" -> "pref_channel_preserve_small_icon_${packageName}_$channelId" + "show_island_icon" -> "pref_channel_show_island_icon_${packageName}_$channelId" + "first_float" -> "pref_channel_first_float_${packageName}_$channelId" + "enable_float" -> "pref_channel_enable_float_${packageName}_$channelId" + "marquee" -> "pref_channel_marquee_${packageName}_$channelId" + "renderer" -> "pref_channel_renderer_${packageName}_$channelId" + "restore_lockscreen" -> "pref_channel_restore_lockscreen_${packageName}_$channelId" + "highlight_color" -> "pref_channel_highlight_color_${packageName}_$channelId" + "show_left_highlight" -> "pref_channel_show_left_highlight_${packageName}_$channelId" + "show_right_highlight" -> "pref_channel_show_right_highlight_${packageName}_$channelId" + else -> null + } ?: return@forEach + if (setting == "highlight_color" && value.isBlank()) { + editor.remove(key) + } else { + editor.putString(key, value) + } + } + } + editor.apply() + } + + suspend fun batchApplyToAllEnabledApps( + settings: Map, + onProgress: (done: Int, total: Int) -> Unit, + ) = withContext(Dispatchers.IO) { + val enabledPackages = loadEnabledPackages().toList() + val total = enabledPackages.size + enabledPackages.forEachIndexed { index, pkg -> + runCatching { + val channels = loadChannels(pkg).orEmpty() + val ids = channels.map { it.id } + if (ids.isNotEmpty()) { + batchApplyChannelSettings(pkg, ids, settings) + } + } + onProgress(index + 1, total) + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt new file mode 100644 index 00000000..758a297d --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt @@ -0,0 +1,35 @@ +package io.github.hyperisland.ui.app + +data class ChannelItem( + val id: String, + val name: String, + val description: String, + val importance: Int, +) + +data class AppChannelsUiState( + val packageName: String = "", + val loading: Boolean = true, + val channels: List = emptyList(), + val enabledChannels: Set = emptySet(), + val channelTemplates: Map = emptyMap(), + val channelTimeout: Map = emptyMap(), + val channelExtras: Map = emptyMap(), + val error: String? = null, +) + +data class ChannelExtraSettings( + val icon: String = "auto", + val focusIcon: String = "auto", + val focus: String = "default", + val preserveSmallIcon: String = "default", + val showIslandIcon: String = "default", + val firstFloat: String = "default", + val enableFloat: String = "default", + val marquee: String = "default", + val renderer: String = "image_text_with_buttons_4", + val restoreLockscreen: String = "default", + val highlightColor: String = "", + val showLeftHighlight: String = "off", + val showRightHighlight: String = "off", +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt new file mode 100644 index 00000000..2a917663 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -0,0 +1,187 @@ +package io.github.hyperisland.ui.app + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AppChannelsViewModel( + app: Application, + private val savedStateHandle: SavedStateHandle, +) : AndroidViewModel(app) { + private val repo = AppAdaptationRepository(app) + private var packageName: String = savedStateHandle["packageName"] ?: "" + + private val _uiState = MutableStateFlow(AppChannelsUiState(packageName = packageName)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val templates = listOf( + "notification_island", + "notification_island_lite", + "download_lite", + "ai_notification_island", + ) + private val iconModes = listOf("auto", "notif_small", "notif_large", "app_icon") + private val triStates = listOf("default", "on", "off") + private val renderers = listOf( + "image_text_with_buttons_4", + "image_text_with_buttons_4_wrap", + "image_text_with_right_text_button", + ) + + init { + refresh() + } + + fun setPackageNameIfEmpty(value: String) { + if (packageName.isNotBlank() || value.isBlank()) return + packageName = value + _uiState.update { it.copy(packageName = value, error = null) } + refresh() + } + + fun refresh() { + if (packageName.isBlank()) { + _uiState.update { it.copy(loading = false, error = "包名为空") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + val channelsResult = runCatching { repo.loadChannels(packageName) } + val channels = channelsResult.getOrNull() + if (channels == null) { + _uiState.update { + it.copy( + loading = false, + error = "无法读取通知渠道,请确认 Root 权限", + ) + } + return@launch + } + + val enabled = repo.getEnabledChannels(packageName) + val templateMap = channels.associate { ch -> + ch.id to repo.getChannelTemplate(packageName, ch.id) + } + val timeoutMap = channels.associate { ch -> + ch.id to repo.getChannelTimeout(packageName, ch.id) + } + val extrasMap = channels.associate { ch -> + ch.id to repo.getChannelExtras(packageName, ch.id) + } + _uiState.update { + it.copy( + loading = false, + channels = channels, + enabledChannels = enabled, + channelTemplates = templateMap, + channelTimeout = timeoutMap, + channelExtras = extrasMap, + ) + } + } + } + + fun toggleChannel(channelId: String, value: Boolean) { + val all = _uiState.value.channels.map { it.id } + val current = _uiState.value.enabledChannels + + val next = if (current.isEmpty()) { + if (value) return + all.filter { it != channelId }.toSet() + } else { + current.toMutableSet().apply { + if (value) add(channelId) else remove(channelId) + }.let { set -> + if (set.size == all.size) emptySet() else set + } + } + + repo.setEnabledChannels(packageName, next) + _uiState.update { it.copy(enabledChannels = next) } + } + + fun enableAllChannels() { + repo.setEnabledChannels(packageName, emptySet()) + _uiState.update { it.copy(enabledChannels = emptySet()) } + } + + fun cycleTemplate(channelId: String) { + val current = _uiState.value.channelTemplates[channelId] ?: templates.first() + val idx = templates.indexOf(current).takeIf { it >= 0 } ?: 0 + val next = templates[(idx + 1) % templates.size] + repo.setChannelTemplate(packageName, channelId, next) + _uiState.update { it.copy(channelTemplates = it.channelTemplates + (channelId to next)) } + } + + fun setTimeout(channelId: String, timeout: String) { + val normalized = timeout.toIntOrNull()?.coerceIn(1, 30)?.toString() ?: "5" + repo.setChannelTimeout(packageName, channelId, normalized) + _uiState.update { it.copy(channelTimeout = it.channelTimeout + (channelId to normalized)) } + } + + fun cycleSetting(channelId: String, setting: String) { + val current = _uiState.value.channelExtras[channelId] ?: return + val next = when (setting) { + "icon" -> current.copy(icon = nextOf(iconModes, current.icon)) + "focus_icon" -> current.copy(focusIcon = nextOf(iconModes, current.focusIcon)) + "focus" -> current.copy(focus = nextOf(triStates, current.focus)) + "preserve_small_icon" -> current.copy(preserveSmallIcon = nextOf(triStates, current.preserveSmallIcon)) + "show_island_icon" -> current.copy(showIslandIcon = nextOf(triStates, current.showIslandIcon)) + "first_float" -> current.copy(firstFloat = nextOf(triStates, current.firstFloat)) + "enable_float" -> current.copy(enableFloat = nextOf(triStates, current.enableFloat)) + "marquee" -> current.copy(marquee = nextOf(triStates, current.marquee)) + "renderer" -> current.copy(renderer = nextOf(renderers, current.renderer)) + "restore_lockscreen" -> current.copy(restoreLockscreen = nextOf(triStates, current.restoreLockscreen)) + "show_left_highlight" -> current.copy(showLeftHighlight = nextOf(listOf("off", "on"), current.showLeftHighlight)) + "show_right_highlight" -> current.copy(showRightHighlight = nextOf(listOf("off", "on"), current.showRightHighlight)) + else -> return + } + val value = when (setting) { + "icon" -> next.icon + "focus_icon" -> next.focusIcon + "focus" -> next.focus + "preserve_small_icon" -> next.preserveSmallIcon + "show_island_icon" -> next.showIslandIcon + "first_float" -> next.firstFloat + "enable_float" -> next.enableFloat + "marquee" -> next.marquee + "renderer" -> next.renderer + "restore_lockscreen" -> next.restoreLockscreen + "show_left_highlight" -> next.showLeftHighlight + "show_right_highlight" -> next.showRightHighlight + else -> return + } + repo.setChannelSetting(packageName, channelId, setting, value) + _uiState.update { it.copy(channelExtras = it.channelExtras + (channelId to next)) } + } + + fun setHighlightColor(channelId: String, color: String) { + repo.setChannelSetting(packageName, channelId, "highlight_color", color.trim()) + val current = _uiState.value.channelExtras[channelId] ?: return + _uiState.update { + it.copy(channelExtras = it.channelExtras + (channelId to current.copy(highlightColor = color.trim()))) + } + } + + fun batchApplyToEnabledChannels(settings: Map) { + val state = _uiState.value + val ids = if (state.enabledChannels.isEmpty()) { + state.channels.map { it.id } + } else { + state.enabledChannels.toList() + } + repo.batchApplyChannelSettings(packageName, ids, settings) + refresh() + } + + private fun nextOf(options: List, current: String): String { + val idx = options.indexOf(current).takeIf { it >= 0 } ?: 0 + return options[(idx + 1) % options.size] + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt new file mode 100644 index 00000000..72b3de7e --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -0,0 +1,356 @@ +package io.github.hyperisland.ui.app + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState +import top.yukonga.miuix.kmp.overlay.OverlayDialog as MiuixOverlayDialog + +@Composable +fun AppsScreen( + state: AppsUiState, + onRefresh: () -> Unit, + onQueryChange: (String) -> Unit, + onAppEnabledChange: (String, Boolean) -> Unit, + onOpenAppChannels: (String) -> Unit, + onBatchApplyGlobal: (Map) -> Unit, + batchRequestId: Int = 0, + modifier: Modifier = Modifier, +) { + var showBatchDialog by remember { mutableStateOf(false) } + LaunchedEffect(batchRequestId) { + if (batchRequestId > 0) showBatchDialog = true + } + val pullToRefreshState = rememberPullToRefreshState() + val filtered = state.apps.filter { app -> + val matchSystem = state.showSystemApps || !app.isSystem || state.enabledPackages.contains(app.packageName) + val q = state.query.trim().lowercase() + val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) + matchSystem && matchQuery + } + + MiuixPullToRefresh( + isRefreshing = state.loading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + pullToRefreshState = pullToRefreshState, + refreshTexts = listOf( + "下拉刷新", + "松开刷新", + "正在刷新...", + ), + ) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + MiuixTextField( + value = state.query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + label = "搜索应用 / 包名", + singleLine = true, + ) + + if (state.loading && state.apps.isEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + MiuixCircularProgressIndicator() + } + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text("已启用应用:${state.enabledPackages.size}") + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(filtered, key = { it.packageName }) { app -> + val enabled = state.enabledPackages.contains(app.packageName) + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenAppChannels(app.packageName) }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(app.appName, fontWeight = FontWeight.SemiBold) + Text(app.packageName, style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = enabled, + onCheckedChange = { onAppEnabledChange(app.packageName, it) }, + ) + } + } + } + } + } + } + if (showBatchDialog) { + BatchApplyDialog( + onDismiss = { showBatchDialog = false }, + onApply = { settings -> + showBatchDialog = false + onBatchApplyGlobal(settings) + }, + ) + } +} + +@Composable +fun AppChannelsScreen( + state: AppChannelsUiState, + onRefresh: () -> Unit, + onToggleChannel: (String, Boolean) -> Unit, + onEnableAllChannels: () -> Unit, + onCycleTemplate: (String) -> Unit, + onSetTimeout: (String, String) -> Unit, + onCycleSetting: (String, String) -> Unit, + onSetHighlightColor: (String, String) -> Unit, + onBatchApplyToEnabledChannels: (Map) -> Unit, +) { + val channels = state.channels + var showBatchDialog by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text(state.packageName, style = MaterialTheme.typography.bodyMedium) + + if (state.loading) { + Spacer(modifier = Modifier.height(20.dp)) + MiuixCircularProgressIndicator() + return + } + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixButton(onClick = onRefresh) { Text("重试") } + } + return + } + + Spacer(modifier = Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixButton(onClick = onEnableAllChannels) { Text("全部渠道生效") } + MiuixButton(onClick = { showBatchDialog = true }) { Text("批量应用") } + } + Spacer(modifier = Modifier.height(8.dp)) + + if (channels.isEmpty()) { + Text( + "未读取到通知渠道,可尝试点击“重试”或确认 Root 权限", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + MiuixButton(onClick = onRefresh) { Text("重试") } + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(channels, key = { it.id }) { channel -> + val enabled = state.enabledChannels.isEmpty() || state.enabledChannels.contains(channel.id) + val template = state.channelTemplates[channel.id] ?: "notification_island" + val timeout = state.channelTimeout[channel.id] ?: "5" + val extras = state.channelExtras[channel.id] ?: ChannelExtraSettings() + ChannelCard( + channel = channel, + enabled = enabled, + template = template, + timeout = timeout, + extras = extras, + onEnableChange = { onToggleChannel(channel.id, it) }, + onCycleTemplate = { onCycleTemplate(channel.id) }, + onSetTimeout = { onSetTimeout(channel.id, it) }, + onCycleSetting = { setting -> onCycleSetting(channel.id, setting) }, + onSetHighlightColor = { onSetHighlightColor(channel.id, it) }, + ) + } + } + } + } + + if (showBatchDialog) { + BatchApplyDialog( + onDismiss = { showBatchDialog = false }, + onApply = { settings -> + showBatchDialog = false + onBatchApplyToEnabledChannels(settings) + }, + ) + } +} + +@Composable +private fun ChannelCard( + channel: ChannelItem, + enabled: Boolean, + template: String, + timeout: String, + extras: ChannelExtraSettings, + onEnableChange: (Boolean) -> Unit, + onCycleTemplate: () -> Unit, + onSetTimeout: (String) -> Unit, + onCycleSetting: (String) -> Unit, + onSetHighlightColor: (String) -> Unit, +) { + var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(channel.name, fontWeight = FontWeight.SemiBold) + Text(channel.id, style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = enabled, + onCheckedChange = onEnableChange, + ) + } + Text("重要性: ${channel.importance}") + if (channel.description.isNotBlank()) { + Text("描述: ${channel.description}", style = MaterialTheme.typography.bodySmall) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("模板: $template") + MiuixButton(onClick = onCycleTemplate) { + Text("切换模板") + } + } + + MiuixTextField( + value = timeout, + onValueChange = { onSetTimeout(it) }, + label = "超时秒数(1-30)", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + SettingsCycleRow("图标来源", extras.icon) { onCycleSetting("icon") } + SettingsCycleRow("焦点图标", extras.focusIcon) { onCycleSetting("focus_icon") } + SettingsCycleRow("焦点通知", extras.focus) { onCycleSetting("focus") } + SettingsCycleRow("保留状态栏小图标", extras.preserveSmallIcon) { onCycleSetting("preserve_small_icon") } + SettingsCycleRow("显示岛图标", extras.showIslandIcon) { onCycleSetting("show_island_icon") } + SettingsCycleRow("首次展开", extras.firstFloat) { onCycleSetting("first_float") } + SettingsCycleRow("更新展开", extras.enableFloat) { onCycleSetting("enable_float") } + SettingsCycleRow("跑马灯", extras.marquee) { onCycleSetting("marquee") } + SettingsCycleRow("渲染器", extras.renderer) { onCycleSetting("renderer") } + SettingsCycleRow("锁屏恢复", extras.restoreLockscreen) { onCycleSetting("restore_lockscreen") } + SettingsCycleRow("左侧高亮", extras.showLeftHighlight) { onCycleSetting("show_left_highlight") } + SettingsCycleRow("右侧高亮", extras.showRightHighlight) { onCycleSetting("show_right_highlight") } + MiuixTextField( + value = highlightDraft, + onValueChange = { + highlightDraft = it + onSetHighlightColor(it) + }, + label = "高亮颜色(#RRGGBB,可空)", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun SettingsCycleRow(title: String, value: String, onCycle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("$title: $value", modifier = Modifier.weight(1f)) + MiuixButton(onClick = onCycle) { Text("切换") } + } +} + +@Composable +private fun BatchApplyDialog( + onDismiss: () -> Unit, + onApply: (Map) -> Unit, +) { + var template by remember { mutableStateOf("notification_island") } + var timeout by remember { mutableStateOf("5") } + var focus by remember { mutableStateOf("default") } + MiuixOverlayDialog( + show = true, + title = "批量应用到已启用渠道", + summary = "", + onDismissRequest = onDismiss, + onDismissFinished = {}, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixTextField( + value = template, + onValueChange = { template = it }, + label = "模板ID", + singleLine = true, + ) + MiuixTextField( + value = timeout, + onValueChange = { timeout = it }, + label = "超时(1-30)", + singleLine = true, + ) + MiuixTextField( + value = focus, + onValueChange = { focus = it }, + label = "焦点通知(default/on/off)", + singleLine = true, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text("取消") + } + MiuixButton( + onClick = { + onApply( + mapOf( + "template" to template.ifBlank { "notification_island" }, + "timeout" to (timeout.toIntOrNull()?.coerceIn(1, 30)?.toString() ?: "5"), + "focus" to focus.ifBlank { "default" }, + ), + ) + }, + modifier = Modifier.weight(1f), + ) { + Text("应用") + } + } + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt new file mode 100644 index 00000000..f8f883c9 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt @@ -0,0 +1,17 @@ +package io.github.hyperisland.ui.app + +data class AppItem( + val packageName: String, + val appName: String, + val isSystem: Boolean, +) + +data class AppsUiState( + val loading: Boolean = true, + val applying: Boolean = false, + val query: String = "", + val showSystemApps: Boolean = false, + val apps: List = emptyList(), + val enabledPackages: Set = emptySet(), + val error: String? = null, +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt new file mode 100644 index 00000000..5a504c33 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt @@ -0,0 +1,86 @@ +package io.github.hyperisland.ui.app + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AppsViewModel(app: Application) : AndroidViewModel(app) { + private val repo = AppAdaptationRepository(app) + + private val _uiState = MutableStateFlow(AppsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + val enabled = repo.loadEnabledPackages() + runCatching { + repo.loadInstalledApps() + }.onSuccess { apps -> + _uiState.update { + it.copy( + loading = false, + apps = apps, + enabledPackages = enabled, + ) + } + }.onFailure { e -> + _uiState.update { + it.copy( + loading = false, + error = e.message ?: "加载应用列表失败", + enabledPackages = enabled, + ) + } + } + } + } + + fun setQuery(value: String) { + _uiState.update { it.copy(query = value) } + } + + fun setShowSystemApps(value: Boolean) { + _uiState.update { it.copy(showSystemApps = value) } + } + + fun setEnabled(packageName: String, enabled: Boolean) { + val next = _uiState.value.enabledPackages.toMutableSet().apply { + if (enabled) add(packageName) else remove(packageName) + }.toSet() + repo.setEnabledPackages(next) + _uiState.update { it.copy(enabledPackages = next) } + } + + fun batchApplyToAllEnabledApps(settings: Map) { + val enabledCount = _uiState.value.enabledPackages.size + if (enabledCount == 0) { + viewModelScope.launch { _events.emit("当前没有已启用应用") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(applying = true) } + repo.batchApplyToAllEnabledApps(settings) { done, total -> + viewModelScope.launch { + _events.emit("批量进度: $done/$total") + } + } + _uiState.update { it.copy(applying = false) } + _events.emit("全局批量应用完成($enabledCount 个应用)") + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistRepository.kt new file mode 100644 index 00000000..bed3db99 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistRepository.kt @@ -0,0 +1,35 @@ +package io.github.hyperisland.ui.blacklist + +import android.content.Context +import io.github.hyperisland.data.prefs.PrefKeys +import io.github.hyperisland.ui.app.AppAdaptationRepository +import io.github.hyperisland.ui.app.AppItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BlacklistRepository(context: Context) { + private val prefs = context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + private val appRepo = AppAdaptationRepository(context) + + suspend fun loadApps(): List = withContext(Dispatchers.IO) { appRepo.loadInstalledApps() } + + fun loadBlacklistedPackages(): Set { + val csv = prefs.getString(PrefKeys.APP_BLACKLIST, "") ?: "" + return if (csv.isBlank()) emptySet() else csv.split(",").filter { it.isNotBlank() }.toSet() + } + + fun saveBlacklistedPackages(packages: Set) { + prefs.edit().putString(PrefKeys.APP_BLACKLIST, packages.joinToString(",")).apply() + } + + fun applyGamePreset(allApps: List, existing: Set): Pair, Int> { + val next = existing.toMutableSet() + var added = 0 + allApps.forEach { app -> + if (app.packageName in GamePresets.PACKAGES && next.add(app.packageName)) { + added += 1 + } + } + return next.toSet() to added + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt new file mode 100644 index 00000000..40e93a00 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt @@ -0,0 +1,107 @@ +package io.github.hyperisland.ui.blacklist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField + +@Composable +fun BlacklistScreen( + state: BlacklistUiState, + onRefresh: () -> Unit, + onQueryChange: (String) -> Unit, + onShowSystemChange: (Boolean) -> Unit, + onSetBlacklisted: (String, Boolean) -> Unit, + onEnableAllVisible: () -> Unit, + onDisableAllVisible: () -> Unit, + onApplyGamePreset: () -> Unit, +) { + val filtered = state.apps.filter { app -> + val matchSystem = state.showSystemApps || !app.isSystem || state.blacklistedPackages.contains(app.packageName) + val q = state.query.trim().lowercase() + val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) + matchSystem && matchQuery + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + MiuixTextField( + value = state.query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + label = "搜索应用 / 包名", + singleLine = true, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("显示系统应用") + MiuixSwitch(checked = state.showSystemApps, onCheckedChange = onShowSystemChange) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixButton(onClick = onApplyGamePreset) { Text("游戏预设") } + MiuixButton(onClick = onEnableAllVisible) { Text("全部加入") } + MiuixButton(onClick = onDisableAllVisible) { Text("全部移除") } + MiuixButton(onClick = onRefresh) { Text("刷新") } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text("黑名单应用:${state.blacklistedPackages.size}") + + if (state.loading) { + Spacer(modifier = Modifier.height(20.dp)) + MiuixCircularProgressIndicator() + return + } + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(filtered, key = { it.packageName }) { app -> + val enabled = state.blacklistedPackages.contains(app.packageName) + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(app.appName, fontWeight = FontWeight.SemiBold) + Text(app.packageName, style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = enabled, + onCheckedChange = { onSetBlacklisted(app.packageName, it) }, + ) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt new file mode 100644 index 00000000..6a2ce5b8 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt @@ -0,0 +1,12 @@ +package io.github.hyperisland.ui.blacklist + +import io.github.hyperisland.ui.app.AppItem + +data class BlacklistUiState( + val loading: Boolean = true, + val query: String = "", + val showSystemApps: Boolean = false, + val apps: List = emptyList(), + val blacklistedPackages: Set = emptySet(), + val error: String? = null, +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt new file mode 100644 index 00000000..662899a9 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt @@ -0,0 +1,95 @@ +package io.github.hyperisland.ui.blacklist + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class BlacklistViewModel(app: Application) : AndroidViewModel(app) { + private val repo = BlacklistRepository(app) + + private val _uiState = MutableStateFlow(BlacklistUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + val blacklisted = repo.loadBlacklistedPackages() + runCatching { repo.loadApps() } + .onSuccess { apps -> + _uiState.update { + it.copy(loading = false, apps = apps, blacklistedPackages = blacklisted) + } + } + .onFailure { e -> + _uiState.update { + it.copy(loading = false, blacklistedPackages = blacklisted, error = e.message ?: "加载失败") + } + } + } + } + + fun setQuery(query: String) { + _uiState.update { it.copy(query = query) } + } + + fun setShowSystemApps(value: Boolean) { + _uiState.update { it.copy(showSystemApps = value) } + } + + fun setBlacklisted(packageName: String, enabled: Boolean) { + val next = _uiState.value.blacklistedPackages.toMutableSet().apply { + if (enabled) add(packageName) else remove(packageName) + }.toSet() + repo.saveBlacklistedPackages(next) + _uiState.update { it.copy(blacklistedPackages = next) } + } + + fun enableAllVisible() { + val visible = currentVisibleApps().map { it.packageName } + val next = _uiState.value.blacklistedPackages.toMutableSet().apply { addAll(visible) }.toSet() + repo.saveBlacklistedPackages(next) + _uiState.update { it.copy(blacklistedPackages = next) } + } + + fun disableAllVisible() { + val visible = currentVisibleApps().map { it.packageName }.toSet() + val next = _uiState.value.blacklistedPackages.toMutableSet().apply { removeAll(visible) }.toSet() + repo.saveBlacklistedPackages(next) + _uiState.update { it.copy(blacklistedPackages = next) } + } + + fun applyGamePreset() { + val state = _uiState.value + val (next, added) = repo.applyGamePreset(state.apps, state.blacklistedPackages) + if (added > 0) { + repo.saveBlacklistedPackages(next) + _uiState.update { it.copy(blacklistedPackages = next) } + } + viewModelScope.launch { + _events.emit("已新增 $added 个游戏到黑名单") + } + } + + private fun currentVisibleApps() = _uiState.value.apps.filter { app -> + val state = _uiState.value + val matchSystem = state.showSystemApps || !app.isSystem || state.blacklistedPackages.contains(app.packageName) + val q = state.query.trim().lowercase() + val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) + matchSystem && matchQuery + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/GamePresets.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/GamePresets.kt new file mode 100644 index 00000000..23c33124 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/GamePresets.kt @@ -0,0 +1,312 @@ +package io.github.hyperisland.ui.blacklist + +object GamePresets { + val PACKAGES: Set = setOf( + "com.miHoYo.Yuanshen", + "com.miHoYo.GenshinImpact", + "com.miHoYo.ys.bilibili", + "com.kurogame.mingchao", + "com.kurogame.wutheringwaves.global", + "com.miHoYo.hkrpg", + "com.miHoYo.hkrpg.bilibili", + "com.HoYoverse.hkrpgoversea", + "com.tencent.tmgp.sgame", + "com.tencent.tmgp.sgamece", + "com.garena.game.kgtw", + "com.tencent.lolm", + "com.levelinfinite.sgameGlobal", + "com.levelinfinite.sgameGlobal.midaspay", + "com.tencent.tmgp.pubgmhd", + "com.tencent.tmgp.pubgmhdce", + "com.tencent.ig", + "com.pubg.imobile", + "com.pubg.krmobile", + "com.rekoo.pubgm", + "com.vng.pubgmobile", + "com.tencent.tmgp.speedmobile", + "com.garena.game.fctw", + "com.dw.h5yvzr.yt", + "com.pwrd.hotta.laohu", + "com.hottagames.hotta.bilibili", + "com.hottagames.hotta.mi", + "com.activision.callofduty.warzone", + "com.tencent.tmgp.cod", + "com.epicgames.fortnite", + "com.netease.l22", + "com.netease.l22.mi", + "com.netease.l22.nearme.gamecenter", + "com.tencent.tmgp.gnyx", + "com.netease.party", + "com.netease.party.nearme.gamecenter", + "com.netease.party.vivo", + "com.netease.party.bilibili", + "com.netease.party.mi", + "com.tencent.tmgp.party", + "com.tencent.letsgo", + "com.netease.dwrg", + "com.netease.dwrg.mi", + "com.tencent.tmgp.dwrg", + "com.netease.dwrg.guopan", + "com.netease.dwrg.bili", + "com.netease.dwrg.nearme.gamecenter", + "com.netease.dwrg5.vivo", + "com.netease.idv.googleplay", + "com.tencent.mf.uam", + "com.proximabeta.mf.uamo", + "com.netease.yyslscn", + "com.netease.aceracer", + "com.netease.aceracer.aligames", + "com.netease.aceracer.nubia", + "com.netease.aceracer.vivo", + "com.netease.aceracer.mi", + "com.netease.aceracer.nearme.gamecenter", + "com.netease.aceracer.huawei", + "com.netease.nshm", + "com.tencent.KiHan", + "com.kurogame.haru.hero", + "com.kurogame.haru.mi", + "com.kurogame.haru.aligames", + "com.kurogame.haru.bilibili", + "com.hypergryph.arknights", + "tw.txwy.and.arknights", + "com.miHoYo.enterprise.NGHSoD", + "com.miHoYo.bh3.mi", + "com.miHoYo.bh3.bilibili", + "com.tencent.tmgp.cf", + "com.tencent.jkchess", + "com.netease.hyxd.mi", + "com.netease.hyxd.aligames", + "com.netease.hyxd.nearme.gamecenter", + "com.netease.hyxd.wyzymnqsd_cps", + "com.tencent.tmgp.dfm", + "com.proxima.dfm", + "com.tencent.tmgp.supercell.clashofclans", + "com.supercell.clashofclans", + "com.hermes.h1game", + "com.hermes.h1game.m4399", + "com.netease.mrzh", + "com.netease.mrzh.mi", + "com.netease.mrzh.nearme.gamecenter", + "com.pi.czrxdfirst", + "cn.jj.chess", + "cn.jj.chess.mi", + "com.tencent.tmgp.dnf", + "com.nexon.mdnf", + "com.bilibili.azurlane", + "com.papegames.infinitynikki", + "com.netease.moba", + "com.blizzard.wtcg.hearthstone", + "com.blizzard.wtcg.hearthstone.cn.dashen", + "com.blizzard.wtcg.hearthstone.cn.huawei", + "com.netease.sky", + "com.tgc.sky.android", + "com.netease.sky.nearme.gamecenter", + "com.netease.sky.bilibili", + "com.tencent.tmgp.eyou.eygy", + "com.netease.sky.mi", + "com.netease.sky.m4399", + "com.netease.sky.vivo", + "com.netease.sky.huawei", + "com.miHoYo.Nap", + "com.mihoyo.nap.bilibili", + "com.gameloft.android.GAND.GloftM3HP", + "com.aligames.kuang.kybc.aligames", + "com.tencent.tmgp.aligames.kybc", + "com.aligames.kuang.kybc.mi", + "com.aligames.kuang.kybc.tap", + "com.aligames.kuang.kybc", + "com.supercell.brawlstars", + "com.tencent.tmgp.supercell.brawlstars", + "com.mojang.minecraftpe", + "com.netease.x19", + "com.tencent.tmgp.wdsj666", + "com.minitech.miniworld", + "com.minitech.miniworld.TMobile.mi", + "com.tencent.tmgp.minitech.miniworld", + "com.minitech.miniworld.uc", + "com.playmini.miniworld", + "com.dragonli.projectsnow.lhm", + "com.dragonli.projectsnow.bilibili", + "com.ChillyRoom.DungeonShooter", + "com.Sunborn.SnqxExilium", + "com.sunborn.snqxexilium.glo", + "com.tencent.tmgp.supercell.clashroyale", + "com.supercell.clashroyale", + "com.netease.race", + "com.netease.race.ua", + "com.netease.dfjs", + "com.netease.dfjs.aligames", + "com.netease.dfjs.mi", + "com.netease.onmyoji", + "com.netease.onmyoji.vivo", + "com.netease.onmyoji.wyzymnqsd_cps", + "com.netease.onmyoji.bili", + "com.netease.onmyoji.mi", + "com.axlebolt.standoff2.huawei", + "com.axlebolt.standoff2", + "com.roblox.client", + "com.sofunny.Sausage", + "com.ztgame.bob", + "com.tencent.tmgp.WePop", + "com.hermes.p6game", + "com.hermes.p6game.mi", + "com.hermes.p6game.aligames", + "com.tencent.nikke", + "com.gamamobi.nikke", + "com.proximabeta.nikke", + "com.yingxiong.heroo.nearme.gamecenter", + "com.bf.sgs.hdexp", + "com.bf.sgs.mi", + "com.bf.sgs.hdexp.m4399", + "com.humo.yqqsqz.yw", + "com.humo.yqqsqz.hykb", + "com.humo.yqqsqz.bilibili", + "com.humo.yqqsqz.mi", + "com.tencent.tmgp.codev", + "com.idreamsky.klbqm", + "com.bilibili.star.bili", + "jp.co.craftegg.band", + "com.bushiroad.en.bangdreamgbp", + "net.gamon.bdTW", + "com.netease.newspike", + "com.Nekootan.kfkj.android", + "com.Nekootan.kfkj.yhlm.aligames", + "com.tencent.tmgp.Nekootan.kfkj.yhlm", + "com.Nekootan.kfkj.yhlm.mi", + "com.netease.yhtj", + "com.netease.yhtj.m4399", + "com.netease.yhtj.gg", + "com.netease.yhtj.aligames", + "com.netease.yhtj.mi", + "com.hermes.mk", + "com.sega.pjsekai", + "com.hermes.mk.asia", + "com.hermes.mk.bilibili", + "com.nd.he", + "com.nd.he.mi", + "com.nd.hoa.aligames", + "com.nd.he.gamename.m4399", + "com.tencent.tmgp.coslegend", + "com.RoamingStar.BlueArchive", + "com.nexon.bluearchive", + "com.YostarJP.BlueArchive", + "com.humble.SlayTheSpire", + "com.sqw.cc.sgdbz_ta", + "com.tencent.nfsonline", + "com.neowizgames.game.browndust2", + "com.tencent.pocket", + "com.chillyroom.zhmr.yw", + "com.chillyroom.zhmr.gp", + "com.chillyroom.zhmr.mi", + "com.chillyroom.zhmr.aligames", + "com.ztgame.yyzy", + "com.ztgame.yyzy.aligames", + "com.tencent.tmgp.sskgame", + "com.tencent.YiRen", + "com.netease.tom", + "com.netease.tom.mi", + "com.tencent.tmgp.NBA", + "com.netease.yzs", + "jp.co.cygames.ShadowverseWorldsBeyond", + "com.bairimeng.dmmdzz", + "com.bairimeng.dmmdzz.mi", + "com.bairimeng.dmmdzz.betazone", + "com.bairimeng.dmmdzz.m4399", + "com.bairimeng.dmmdzz.honor", + "com.bairimeng.dmmdzz.vivo", + "com.tencent.tmgp.bairimeng.dmmdzz", + "com.tencent.tmgp.djsy", + "com.qqgame.hlddz", + "com.guigugame.guigubahuang", + "com.gaijingames.wtm", + "com.xindong.torchlight", + "com.tencent.tmgp.qqx5", + "com.tencent.game.rhythmmaster", + "com.netease.allstar", + "com.tencent.nba2kx", + "com.bandainamcoent.idolmaster_gakuen", + "com.lilithgames.solarland.android.cn", + "com.miraclegames.farlight84", + "com.pkwan.op.toufang.dy", + "com.tencent.tmgp.pkwan.op", + "com.tungsten.fcl", + "com.bilibili.heaven", + "com.heavenburnsred.kbinstaller", + "com.heavenburnsred", + "com.hero.sm.bz", + "com.hero.sm.android.hero", + "com.hero.sm.mi", + "com.hero.sm.aligames", + "com.hero.sm.huawei", + "com.tencent.tmgp.sm", + "com.nordcurrent.flyingfever", + "com.tencent.tmgp.fmgame", + "com.yinhan.hunter.yh", + "com.yinhan.hunter.mi", + "com.yinhan.hunter.uc", + "com.yinhan.hunter.huawei", + "com.yinhan.hunter.tx", + "com.yinhan.hunter.qihoo", + "com.netease.sdsbq", + "com.netease.sdsbq.mi", + "com.netease.sdsbq.huawei", + "com.TeamCherry.HollowKnight", + "com.tencent.tmgp.yslzm", + "com.zulong.yslzm.mi", + "com.archosaur.sea.yslzm.gp", + "com.soulgamechst.majsoul", + "com.tapblaze.pizzabusiness", + "com.studiowildcard.arkuse", + "com.duoyi.m2m1", + "com.k7k7.goujihd", + "com.k7k7.goujihd.mi", + "com.k7k7.goujihd.huawei", + "com.hero.dna.gf", + "com.zane.stardewvalley", + "abc.ningban.gameloades", + "com.tencent.nrc", + "com.xuejing.smallfish.official", + "com.xuejing.smallfish.mi", + "com.tencent.tmgp.hldyds", + "com.xuejing.smallfish.huawei", + "gg.com.fishgame.fishon", + "com.dfjz.moba", + "com.mobile.legends", + "gg.com.mobile.legends.lite", + "com.dfjz.moba.aligames", + "com.dfjz.moba.mi", + "com.tencent.tmgp.cfxf", + "com.netease.cfxf.huawei", + "com.netease.cfxf.mi", + "com.rockstargames.rdr", + "com.bilibili.snake", + "com.tencent.tmgp.bilibili.snake", + "com.bilibili.snake.mi", + "com.bilibili.snake.aligames", + "com.bilibili.snake.vivo", + "com.bilibili.snake.huawei", + "com.DefaultCompany.heimalouxiangsutest", + "net.pvz.pgvz.zbcteam", + "com.Lonerangerix.ArrowGame", + "com.jurassic.world.the.cursed.isle.dinosaurs.carnivores.dino.hunter.dinos.online.trex.tyrannosaurus.simulator", + "jp.konami.pesam", + "com.t2ksports.myteam2k26v2", + "com.t2ksports.myteam2k25", + "com.tencent.tmgp.nz", + "com.hypergryph.endfield", + "com.gryphline.endfield.gp", + "com.LoongCharm.infinityworld", + "com.tencent.rmcn", + "com.digitalextremes.warframemobile", + "sh.ppy.osulazer", + "com.sybogames.subway.surfers.game", + "com.netease.harrypotter", + "com.netease.harrypotter.mi", + "com.netease.harrypotter.vivo", + "com.netease.harrypotter.nearme.gamecenter", + "com.tencent.tmgp.harrypotter", + "com.tencent.tmgp.supercell.boombeach", + "com.cipaishe.wuhua.bilibili", + "com.supercell.boombeach", + ) +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeUiState.kt new file mode 100644 index 00000000..90f59773 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeUiState.kt @@ -0,0 +1,8 @@ +package io.github.hyperisland.ui.home + +data class HomeUiState( + val moduleActive: Boolean? = null, + val lsposedApiVersion: Int = 0, + val focusProtocolVersion: Int = 0, + val restarting: Boolean = false, +) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeViewModel.kt new file mode 100644 index 00000000..7eb64169 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/home/HomeViewModel.kt @@ -0,0 +1,102 @@ +package io.github.hyperisland.ui.home + +import android.app.Application +import android.provider.Settings +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.github.hyperisland.HyperIslandApp +import io.github.hyperisland.HyperIslandHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class HomeViewModel(app: Application) : AndroidViewModel(app) { + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + refreshStatus() + } + + fun refreshStatus() { + viewModelScope.launch(Dispatchers.IO) { + val ready = HyperIslandApp.awaitReady() + val apiVersion = if (ready) HyperIslandApp.getApiVersion() else 0 + val active = ready && apiVersion >= 101 + val focusProtocol = Settings.System.getInt( + getApplication().contentResolver, + "notification_focus_protocol", + 0, + ) + _uiState.update { + it.copy( + moduleActive = active, + lsposedApiVersion = apiVersion, + focusProtocolVersion = focusProtocol, + ) + } + } + } + + fun sendTest() { + viewModelScope.launch { + runCatching { + HyperIslandHelper.sendIslandNotification( + getApplication(), + title = "测试通知", + content = "这是一条 HyperIsland 测试通知", + ) + }.onSuccess { + _events.emit("已发送测试通知") + }.onFailure { e -> + _events.emit("发送失败: ${e.message ?: "未知错误"}") + } + } + } + + fun restartScopes( + restartSystemUi: Boolean, + restartDownloadManager: Boolean, + restartXmsf: Boolean, + ) { + val commands = buildList { + if (restartSystemUi) add("killall com.android.systemui") + if (restartDownloadManager) add("am force-stop com.android.providers.downloads") + if (restartXmsf) add("am force-stop com.xiaomi.xmsf") + } + if (commands.isEmpty()) return + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(restarting = true) } + val result = runCatching { + val process = Runtime.getRuntime().exec("su") + val writer = process.outputStream.bufferedWriter() + commands.forEach { cmd -> + writer.write(cmd) + writer.newLine() + } + writer.write("exit") + writer.newLine() + writer.flush() + writer.close() + process.waitFor() + } + + _uiState.update { it.copy(restarting = false) } + if (result.getOrNull() == 0) { + _events.emit("作用域重启命令已执行") + } else { + _events.emit("重启失败,请确认 Root 权限") + } + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..1144e4b9 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt @@ -0,0 +1,145 @@ +package io.github.hyperisland.ui.settings + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.github.hyperisland.data.config.ConfigIoManager +import io.github.hyperisland.data.prefs.PrefKeys +import io.github.hyperisland.data.prefs.SettingsRepository +import io.github.hyperisland.data.prefs.SettingsState +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class SettingsViewModel(app: Application) : AndroidViewModel(app) { + private val repo = SettingsRepository(app) + private val configIo = ConfigIoManager(app) + + private val _uiState = MutableStateFlow(SettingsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + reload() + } + + fun reload() { + _uiState.value = repo.load() + } + + fun updateSwitch(key: String, value: Boolean) { + repo.setBoolean(key, value) + _uiState.update { + when (key) { + PrefKeys.SHOW_WELCOME -> it.copy(showWelcome = value) + PrefKeys.RESUME_NOTIFICATION -> it.copy(resumeNotification = value) + PrefKeys.USE_HOOK_APP_ICON -> it.copy(useHookAppIcon = value) + PrefKeys.INTERACTION_HAPTICS -> it.copy(interactionHaptics = value) + PrefKeys.CHECK_UPDATE_ON_LAUNCH -> it.copy(checkUpdateOnLaunch = value) + PrefKeys.ROUND_ICON -> it.copy(roundIcon = value) + PrefKeys.MARQUEE_FEATURE -> it.copy(marqueeFeature = value) + PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED -> it.copy(bigIslandMaxWidthEnabled = value) + PrefKeys.UNLOCK_ALL_FOCUS -> it.copy(unlockAllFocus = value) + PrefKeys.UNLOCK_FOCUS_AUTH -> it.copy(unlockFocusAuth = value) + PrefKeys.DEFAULT_FIRST_FLOAT -> it.copy(defaultFirstFloat = value) + PrefKeys.DEFAULT_ENABLE_FLOAT -> it.copy(defaultEnableFloat = value) + PrefKeys.DEFAULT_SHOW_ISLAND_ICON -> it.copy(defaultShowIslandIcon = value) + PrefKeys.DEFAULT_MARQUEE -> it.copy(defaultMarquee = value) + PrefKeys.DEFAULT_FOCUS_NOTIF -> it.copy(defaultFocusNotif = value) + PrefKeys.DEFAULT_PRESERVE_SMALL_ICON -> it.copy(defaultPreserveSmallIcon = value) + PrefKeys.DEFAULT_RESTORE_LOCKSCREEN -> it.copy(defaultRestoreLockscreen = value) + else -> it + } + } + } + + fun updateThemeMode(value: String) { + repo.setString(PrefKeys.THEME_MODE, value) + _uiState.update { it.copy(themeMode = value) } + } + + fun updateLocale(value: String?) { + repo.setString(PrefKeys.LOCALE, value) + _uiState.update { it.copy(locale = value) } + } + + fun updateMarqueeSpeed(value: Int) { + repo.setMarqueeSpeed(value) + _uiState.update { it.copy(marqueeSpeed = value.coerceIn(20, 500)) } + } + + fun updateBigIslandMaxWidth(value: Int) { + repo.setBigIslandMaxWidth(value) + _uiState.update { it.copy(bigIslandMaxWidth = value.coerceIn(500, 1000)) } + } + + fun setDesktopIconHidden(hidden: Boolean) { + viewModelScope.launch { + val result = runCatching { + repo.setDesktopIconHidden(hidden) + } + if (result.isSuccess) { + _uiState.update { it.copy(hideDesktopIcon = hidden) } + } else { + _events.emit("桌面图标设置失败: ${result.exceptionOrNull()?.message ?: "未知错误"}") + } + } + } + + fun exportConfigToFile() { + viewModelScope.launch { + runCatching { configIo.exportToFile() } + .onSuccess { _events.emit("配置已导出到: $it") } + .onFailure { _events.emit("导出失败: ${it.message}") } + } + } + + fun importConfigFromFile() { + viewModelScope.launch { + runCatching { configIo.importFromFile() } + .onSuccess { + reload() + _events.emit("配置导入成功,条目数: $it") + } + .onFailure { _events.emit("导入失败: ${it.message}") } + } + } + + fun importConfigFromUri(uri: Uri) { + viewModelScope.launch { + runCatching { configIo.importFromUri(uri) } + .onSuccess { + reload() + _events.emit("文件导入成功,条目数: $it") + } + .onFailure { _events.emit("文件导入失败: ${it.message}") } + } + } + + fun exportConfigToClipboard() { + viewModelScope.launch { + runCatching { configIo.exportToClipboard() } + .onSuccess { _events.emit("配置已复制到剪贴板") } + .onFailure { _events.emit("复制失败: ${it.message}") } + } + } + + fun importConfigFromClipboard() { + viewModelScope.launch { + runCatching { configIo.importFromClipboard() } + .onSuccess { + reload() + _events.emit("剪贴板导入成功,条目数: $it") + } + .onFailure { _events.emit("剪贴板导入失败: ${it.message}") } + } + } +} diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be7..1b9dd290 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -15,4 +15,13 @@ + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef880..5da31455 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,13 @@ + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 6cc9e51b..ace6a7a1 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,6 +20,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false } include(":app") diff --git a/compose_migration_plan.md b/compose_migration_plan.md new file mode 100644 index 00000000..9b33dee5 --- /dev/null +++ b/compose_migration_plan.md @@ -0,0 +1,62 @@ +# Compose 重构清单(可直接开工) + +1. **先定迁移策略** +- 选 `一次性切换` 或 `分阶段切换`。 +- 建议分阶段:先上原生壳和基础页,再迁 Apps/Channels,再删 Flutter。 + +2. **建立新 UI 架构** +- 技术栈:`Compose + Material3 + MiuiX + ViewModel + DataStore(或继续SharedPreferences)`。 +- 目录建议: + - `android/app/src/main/kotlin/io/github/hyperisland/ui/` + - `ui/home`, `ui/apps`, `ui/channels`, `ui/settings`, `ui/ai`, `ui/common` + - `data/prefs`, `data/repo`, `domain` + +3. **配置存储兼容(最关键)** +- 保持所有 `pref_*` 键名完全不变。 +- 保持 CSV/渠道键格式不变(如 `pref_generic_whitelist`, `pref_channel_*`)。 +- 保持 `FlutterSharedPreferences` 文件名或做兼容读取,确保 Xposed `ConfigManager` 无感。 + +4. **原生能力层替换** +- 把 `MethodChannel` 能力改成本地 Kotlin UseCase/Repository: + - 模块状态检测 + - LSPosed API 版本 + - 获取应用列表/图标 + - 读取通知渠道(root + policy xml) + - 重启作用域进程 + - 桌面图标显隐 + +5. **页面迁移顺序** +- 第 1 批:Home(状态检测、测试通知、重启作用域) +- 第 2 批:Apps(白名单列表、搜索、批量开关) +- 第 3 批:Channels(渠道开关、模板、渲染器、高级参数) +- 第 4 批:Settings(全局开关、主题语言、导入导出) +- 第 5 批:AI 配置页与日志 + +6. **状态管理改造** +- 每页 `ViewModel + StateFlow`。 +- 偏好变更统一走 `SettingsRepository`。 +- 监听配置变化时,及时写入并刷新 UI(替代 Flutter `ChangeNotifier`)。 + +7. **国际化迁移** +- 把 Flutter ARB 文案迁移到 `res/values*/strings.xml`。 +- 先迁中文+英文,其他语种第二批补齐。 + +8. **测试与验收** +- 回归重点: + - 配置改动后 Xposed 热生效 + - 白名单/渠道规则命中一致 + - 下载模板与 AI 模板行为一致 + - Root 失败路径与弹窗提示一致 +- 真机验证:`SystemUI / DownloadManager / XMSF` 三作用域都测。 + +9. **清理 Flutter** +- 移除 Dart 与 Flutter 依赖、插件、`lib/` 入口。 +- 清理 `pubspec*`、Flutter Gradle 配置、无用资源。 +- 保留原 Kotlin/Xposed 包结构不动。 + +10. **建议里程碑** +- M1(1周):Home + Settings 基础可用 +- M2(1-2周):Apps + Channels 全量功能 +- M3(3-5天):多语言、测试、移除 Flutter、发布包 + +如果你要,我下一步可以直接给你“可落地的目标文件树 + 首批基础代码骨架(Activity/NavHost/ViewModel/PrefsRepository)”。 From 349303e696c73a83aa4888e862d4db3164788ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Thu, 9 Apr 2026 16:06:54 +0800 Subject: [PATCH 06/14] Polish top bar and nav transitions, unify feedback to Toast --- .../hyperisland/ui/ComposeMainActivity.kt | 2780 ++++++++++++----- 1 file changed, 2077 insertions(+), 703 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index 0e09ed7c..b4988582 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -6,25 +6,45 @@ import android.content.ClipData import android.content.ClipboardManager import android.graphics.BitmapFactory import android.net.Uri +import android.os.Build import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.Image @@ -32,35 +52,62 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.InsertDriveFile -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.animateContentSize import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import kotlinx.coroutines.launch import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.animation.core.animateDpAsState import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavGraph.Companion.findStartDestination @@ -70,15 +117,21 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import io.github.hyperisland.data.prefs.PrefKeys import io.github.hyperisland.data.prefs.SettingsState import io.github.hyperisland.ui.ai.AiConfigScreen import io.github.hyperisland.ui.ai.AiConfigViewModel +import io.github.hyperisland.ui.app.AppItem import io.github.hyperisland.ui.app.AppChannelsScreen import io.github.hyperisland.ui.app.AppChannelsViewModel import io.github.hyperisland.ui.app.AppsScreen +import io.github.hyperisland.ui.app.AppsUiState import io.github.hyperisland.ui.app.AppsViewModel +import io.github.hyperisland.ui.app.ChannelSettingsScreen import io.github.hyperisland.ui.blacklist.BlacklistScreen import io.github.hyperisland.ui.blacklist.BlacklistViewModel import io.github.hyperisland.ui.home.HomeUiState @@ -88,28 +141,48 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.ArrowRight import top.yukonga.miuix.kmp.icon.extended.All import top.yukonga.miuix.kmp.icon.extended.AppRecording +import top.yukonga.miuix.kmp.icon.extended.Create +import top.yukonga.miuix.kmp.icon.extended.File +import top.yukonga.miuix.kmp.icon.extended.Info +import top.yukonga.miuix.kmp.icon.extended.MoreCircle +import top.yukonga.miuix.kmp.icon.extended.Refresh +import top.yukonga.miuix.kmp.icon.extended.Search +import top.yukonga.miuix.kmp.icon.extended.SelectAll import top.yukonga.miuix.kmp.icon.extended.Settings import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults import top.yukonga.miuix.kmp.basic.Card as MiuixCard import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox +import top.yukonga.miuix.kmp.basic.DropdownImpl as MiuixDropdownImpl +import top.yukonga.miuix.kmp.basic.FloatingNavigationBarDisplayMode as MiuixFloatingNavigationBarDisplayMode import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.ListPopupColumn as MiuixListPopupColumn -import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar -import top.yukonga.miuix.kmp.basic.NavigationBarItem as MiuixNavigationBarItem import top.yukonga.miuix.kmp.basic.PopupPositionProvider as MiuixPopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider -import top.yukonga.miuix.kmp.basic.SnackbarHost as MiuixSnackbarHost -import top.yukonga.miuix.kmp.basic.SnackbarHostState as MiuixSnackbarHostState import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar -import top.yukonga.miuix.kmp.basic.SmallTopAppBar as MiuixSmallTopAppBar import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.rememberTopAppBarState -import top.yukonga.miuix.kmp.overlay.OverlayDialog as MiuixOverlayDialog -import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet as MiuixOverlayBottomSheet -import top.yukonga.miuix.kmp.overlay.OverlayListPopup as MiuixOverlayListPopup +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayDialog +import top.yukonga.miuix.kmp.overlay.OverlayListPopup +import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference as MiuixOverlayDropdownPreference import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.pressable +import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import kotlin.math.cos +import kotlin.math.sin + +import top.yukonga.miuix.kmp.blur.LayerBackdrop +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop +import top.yukonga.miuix.kmp.blur.textureBlur +import androidx.compose.runtime.compositionLocalOf + +val LocalContentPadding = compositionLocalOf { PaddingValues(0.dp) } class ComposeMainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -124,6 +197,10 @@ class ComposeMainActivity : ComponentActivity() { android.graphics.Color.TRANSPARENT, ), ) + window.navigationBarColor = android.graphics.Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } setContent { MiuixTheme { MaterialTheme { @@ -140,6 +217,40 @@ private data class TopLevelDestination( val icon: androidx.compose.ui.graphics.vector.ImageVector, ) +private data class NavigationStyleState( + val floating: Boolean, + val floatingMode: MiuixFloatingNavigationBarDisplayMode, + val floatingBottomOffset: Dp, + val floatingHorizontalOutSidePadding: Dp, + val floatingCornerRadius: Dp, + val floatingShadowElevation: Dp, + val floatingWindowInsetsPadding: Boolean, + val floatingContainerWidth: Dp, + val floatingContainerHeight: Dp, + val floatingIconSize: Dp, + val floatingItemHorizontalPadding: Dp, + val floatingStrokeWidth: Dp, + val bottomShowDivider: Boolean, + val bottomWindowInsetsPadding: Boolean, + val bottomContainerHeight: Dp, + val bottomIconSize: Dp, + val bottomItemHorizontalPadding: Dp, + val bottomShowLabel: Boolean, + val unselectedAlpha: Float, +) + +private enum class TopBarVariant { + PrimaryHome, + PrimaryApps, + PrimarySettings, + Secondary, +} + +private data class TopBarStyleState( + val variant: TopBarVariant, + val defaultWindowInsetsPadding: Boolean, +) + private val HomeFilledIcon: ImageVector by lazy { ImageVector.Builder( name = "HomeFilledCustom", @@ -181,6 +292,43 @@ private val HomeFilledIcon: ImageVector by lazy { }.build() } +private val SettingsFilledIcon: ImageVector by lazy { + ImageVector.Builder( + name = "SettingsFilledCustom", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + fill = SolidColor(Color(0xFF292D32)), + pathFillType = PathFillType.NonZero, + ) { + moveTo(18.9401f, 5.42141f) + lineTo(13.7701f, 2.43141f) + curveTo(12.7801f, 1.86141f, 11.2301f, 1.86141f, 10.2401f, 2.43141f) + lineTo(5.02008f, 5.44141f) + curveTo(2.95008f, 6.84141f, 2.83008f, 7.05141f, 2.83008f, 9.28141f) + verticalLineTo(14.7114f) + curveTo(2.83008f, 16.9414f, 2.95008f, 17.1614f, 5.06008f, 18.5814f) + lineTo(10.2301f, 21.5714f) + curveTo(10.7301f, 21.8614f, 11.3701f, 22.0014f, 12.0001f, 22.0014f) + curveTo(12.6301f, 22.0014f, 13.2701f, 21.8614f, 13.7601f, 21.5714f) + lineTo(18.9801f, 18.5614f) + curveTo(21.0501f, 17.1614f, 21.1701f, 16.9514f, 21.1701f, 14.7214f) + verticalLineTo(9.28141f) + curveTo(21.1701f, 7.05141f, 21.0501f, 6.84141f, 18.9401f, 5.42141f) + close() + moveTo(12.0001f, 15.2514f) + curveTo(10.2101f, 15.2514f, 8.75008f, 13.7914f, 8.75008f, 12.0014f) + curveTo(8.75008f, 10.2114f, 10.2101f, 8.75141f, 12.0001f, 8.75141f) + curveTo(13.7901f, 8.75141f, 15.2501f, 10.2114f, 15.2501f, 12.0014f) + curveTo(15.2501f, 13.7914f, 13.7901f, 15.2514f, 12.0001f, 15.2514f) + close() + } + }.build() +} + private fun mainRouteIndex(route: String?): Int = when (route) { "home" -> 0 "apps" -> 1 @@ -188,10 +336,266 @@ private fun mainRouteIndex(route: String?): Int = when (route) { else -> -1 } +private fun routeLevel(route: String?): Int = when { + route == "home" || route == "apps" || route == "settings" -> 1 + route == "blacklist" || route == "ai_config" || route?.startsWith("app_channels/") == true -> 2 + route?.startsWith("channel_settings/") == true -> 3 + else -> 1 +} + +private fun resolveRouteSlideDirection( + fromRoute: String?, + toRoute: String?, +): AnimatedContentTransitionScope.SlideDirection? { + if (fromRoute == toRoute) return null + + val fromMain = mainRouteIndex(fromRoute) + val toMain = mainRouteIndex(toRoute) + if (fromMain >= 0 && toMain >= 0 && fromMain != toMain) { + return if (toMain > fromMain) { + AnimatedContentTransitionScope.SlideDirection.Left + } else { + AnimatedContentTransitionScope.SlideDirection.Right + } + } + + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + if (fromLevel == toLevel) { + return AnimatedContentTransitionScope.SlideDirection.Left + } + + return if (toLevel > fromLevel) { + AnimatedContentTransitionScope.SlideDirection.Left + } else { + AnimatedContentTransitionScope.SlideDirection.Right + } +} + private const val DOCUMENTATION_URL = "https://hyperisland.1812z.top/" private const val GITHUB_REPO_URL = "https://github.com/1812z/HyperIsland" private const val GITHUB_RELEASE_URL = "https://github.com/1812z/HyperIsland/releases/latest" private const val QQ_GROUP_NUMBER = "1045114341" +private const val DEFAULT_MARQUEE_SPEED = 100 +private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 600 + +@Composable +private fun HyperCeilerNavItem( + destination: TopLevelDestination, + selected: Boolean, + showLabel: Boolean, + iconSize: Dp, + itemHorizontalPadding: Dp, + unselectedAlpha: Float, + suppressPressEffect: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val alpha = if (selected) 1f else unselectedAlpha + val clickableModifier = if (suppressPressEffect) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + } else { + Modifier.clickable(onClick = onClick) + } + Column( + modifier = modifier + .fillMaxSize() + .then(clickableModifier) + .padding(horizontal = itemHorizontalPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + modifier = Modifier.size(iconSize), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + ) + if (showLabel) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = destination.label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + maxLines = 1, + ) + } + } +} + +@Composable +private fun HyperCeilerNavigationSwitchBar( + items: List, + selectedIndex: Int, + style: NavigationStyleState, + onDestinationClick: (TopLevelDestination) -> Unit, + modifier: Modifier = Modifier, + backdrop: LayerBackdrop? = null, +) { + val navBottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AnimatedContent( + targetState = style.floating, + label = "nav_style_transition", + transitionSpec = { + if (targetState) { + ( + fadeIn(animationSpec = tween(durationMillis = 320)) + + slideInVertically(animationSpec = tween(durationMillis = 320), initialOffsetY = { it / 3 }) + + scaleIn(animationSpec = tween(durationMillis = 320), initialScale = 0.94f) + ) togetherWith + ( + fadeOut(animationSpec = tween(durationMillis = 220)) + + slideOutVertically(animationSpec = tween(durationMillis = 220), targetOffsetY = { it / 4 }) + + scaleOut(animationSpec = tween(durationMillis = 220), targetScale = 0.98f) + ) + } else { + ( + fadeIn(animationSpec = tween(durationMillis = 280)) + + slideInVertically(animationSpec = tween(durationMillis = 280), initialOffsetY = { it / 4 }) + + scaleIn(animationSpec = tween(durationMillis = 280), initialScale = 0.98f) + ) togetherWith + ( + fadeOut(animationSpec = tween(durationMillis = 220)) + + slideOutVertically(animationSpec = tween(durationMillis = 220), targetOffsetY = { it / 3 }) + + scaleOut(animationSpec = tween(durationMillis = 220), targetScale = 0.96f) + ) + } + }, + ) { isFloating -> + if (isFloating) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(start = style.floatingHorizontalOutSidePadding, end = style.floatingHorizontalOutSidePadding) + .padding( + bottom = style.floatingBottomOffset + + if (style.floatingWindowInsetsPadding) navBottomInset else 0.dp, + ), + contentAlignment = Alignment.BottomCenter, + ) { + Row( + modifier = Modifier + .width(style.floatingContainerWidth) + .height(style.floatingContainerHeight) + .shadow( + elevation = style.floatingShadowElevation, + shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), + ambientColor = Color.Black.copy(alpha = 0.10f), + spotColor = Color.Black.copy(alpha = 0.12f), + clip = false, + ) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius)) + .let { if (backdrop != null) it.textureBlur(backdrop, shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), blurRadiusX = 32f, blurRadiusY = 32f) else it } + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color(0xFFFFFFFF).copy(alpha = if (backdrop != null) 0.65f else 1f), + Color(0xFFFAFAFB).copy(alpha = if (backdrop != null) 0.65f else 1f), + Color(0xFFF3F4F6).copy(alpha = if (backdrop != null) 0.65f else 1f), + ), + ), + ) + .drawWithCache { + val halfW = size.width / 2f + val halfH = size.height / 2f + val corner = style.floatingCornerRadius.toPx() + val outerStroke = (style.floatingStrokeWidth * 1.10f).toPx() + val innerStroke = (style.floatingStrokeWidth * 0.58f).toPx() + // HyperCeiler 风格:高光从左上打向右下,角度约 34°。 + val strokeAngleRad = Math.toRadians(34.0) + val dx = (cos(strokeAngleRad) * halfW).toFloat() + val dy = (sin(strokeAngleRad) * halfH).toFloat() + val outerStrokeBrush = Brush.linearGradient( + colors = listOf( + Color.White.copy(alpha = 0.97f), + Color(0xFFF1F2F5).copy(alpha = 0.78f), + Color(0xFFE2E4E9).copy(alpha = 0.62f), + Color.White.copy(alpha = 0.93f), + ), + start = Offset(halfW - dx, halfH - dy), + end = Offset(halfW + dx, halfH + dy), + ) + val innerStrokeBrush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.84f), + Color.White.copy(alpha = 0.28f), + Color.Transparent, + ), + startY = 0f, + endY = size.height * 0.70f, + ) + onDrawWithContent { + drawContent() + drawRoundRect( + brush = outerStrokeBrush, + size = Size(size.width, size.height), + cornerRadius = CornerRadius(corner, corner), + style = Stroke(width = outerStroke), + ) + drawRoundRect( + brush = innerStrokeBrush, + size = Size(size.width, size.height * 0.78f), + cornerRadius = CornerRadius(corner, corner), + style = Stroke(width = innerStroke), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, destination -> + HyperCeilerNavItem( + destination = destination, + selected = index == selectedIndex, + showLabel = style.floatingMode != MiuixFloatingNavigationBarDisplayMode.IconOnly, + iconSize = style.floatingIconSize, + itemHorizontalPadding = style.floatingItemHorizontalPadding, + unselectedAlpha = style.unselectedAlpha, + suppressPressEffect = true, + onClick = { onDestinationClick(destination) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } else { + Column( + modifier = modifier + .fillMaxWidth() + .let { if (backdrop != null) it.textureBlur(backdrop, shape = androidx.compose.ui.graphics.RectangleShape, blurRadiusX = 32f, blurRadiusY = 32f) else it } + .background(MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.7f else 1f)) + ) { + if (style.bottomShowDivider) { + HorizontalDivider() + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (style.bottomWindowInsetsPadding) navBottomInset else 0.dp) + .height(style.bottomContainerHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, destination -> + HyperCeilerNavItem( + destination = destination, + selected = index == selectedIndex, + showLabel = style.bottomShowLabel, + iconSize = style.bottomIconSize, + itemHorizontalPadding = style.bottomItemHorizontalPadding, + unselectedAlpha = style.unselectedAlpha, + suppressPressEffect = false, + onClick = { onDestinationClick(destination) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} private fun routeTitle(route: String?): String { return when { @@ -205,18 +609,6 @@ private fun routeTitle(route: String?): String { } } -private fun routeLargeTitle(route: String?): String { - return when { - route == "home" -> "主页" - route == "apps" -> "应用适配" - route == "settings" -> "系统设置" - route?.startsWith("app_channels/") == true -> "通知渠道" - route == "blacklist" -> "通知黑名单" - route == "ai_config" -> "AI 配置" - else -> "HyperIsland" - } -} - private fun openExternalUrl(context: Context, url: String) { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { @@ -229,345 +621,985 @@ private fun openExternalUrl(context: Context, url: String) { @Composable private fun HyperIslandComposeApp() { val navController = rememberNavController() - val snackbarHostState = remember { MiuixSnackbarHostState() } + val navigationEventDispatcherOwner = rememberNavigationEventDispatcherOwner(parent = null) + val backdrop = rememberLayerBackdrop() val context = LocalContext.current val homeVm: HomeViewModel = viewModel() val appsVm: AppsViewModel = viewModel() + val blacklistVm: BlacklistViewModel = viewModel() + val settingsVm: SettingsViewModel = viewModel() + val settingsState by settingsVm.uiState.collectAsStateWithLifecycle() val appsState by appsVm.uiState.collectAsStateWithLifecycle() + val blacklistState by blacklistVm.uiState.collectAsStateWithLifecycle() var showRestartDialog by remember { mutableStateOf(false) } var showSponsorDialog by remember { mutableStateOf(false) } var showAppsMenu by remember { mutableStateOf(false) } + var showBlacklistMenu by remember { mutableStateOf(false) } + var appsSelectionMode by remember { mutableStateOf(false) } + var appsSelectionRequestId by remember { mutableStateOf(0) } + var appsExitSelectionRequestId by remember { mutableStateOf(0) } + var appsEnableSelectedRequestId by remember { mutableStateOf(0) } + var appsDisableSelectedRequestId by remember { mutableStateOf(0) } + var appsSelectEnabledRequestId by remember { mutableStateOf(0) } + var appsBatchSelectedRequestId by remember { mutableStateOf(0) } + var appsEnableAllRequestId by remember { mutableStateOf(0) } + var appsDisableAllRequestId by remember { mutableStateOf(0) } var appsBatchRequestId by remember { mutableStateOf(0) } + var showAppChannelsMenu by remember { mutableStateOf(false) } + var appChannelsEnableAllRequestId by remember { mutableStateOf(0) } + var appChannelsBatchRequestId by remember { mutableStateOf(0) } + val popupShowing = showAppsMenu || showAppChannelsMenu || showBlacklistMenu val items = listOf( TopLevelDestination("home", "主页", HomeFilledIcon), TopLevelDestination("apps", "应用", MiuixIcons.Regular.All), - TopLevelDestination("settings", "设置", MiuixIcons.Regular.Settings), + TopLevelDestination("settings", "设置", SettingsFilledIcon), ) + val capsuleNavStyleState = remember { + NavigationStyleState( + floating = true, + floatingMode = MiuixFloatingNavigationBarDisplayMode.IconOnly, + floatingBottomOffset = 12.dp, + floatingHorizontalOutSidePadding = 24.dp, + floatingCornerRadius = 26.dp, + floatingShadowElevation = 4.dp, + floatingWindowInsetsPadding = true, + floatingContainerWidth = 220.dp, + floatingContainerHeight = 56.dp, + floatingIconSize = 22.dp, + floatingItemHorizontalPadding = 16.dp, + floatingStrokeWidth = 1.6.dp, + bottomShowDivider = false, + bottomWindowInsetsPadding = false, + bottomContainerHeight = 0.dp, + bottomIconSize = 0.dp, + bottomItemHorizontalPadding = 0.dp, + bottomShowLabel = false, + unselectedAlpha = 0.4f, + ) + } + val bottomNavStyleState = remember { + NavigationStyleState( + floating = false, + floatingMode = MiuixFloatingNavigationBarDisplayMode.IconOnly, + floatingBottomOffset = 0.dp, + floatingHorizontalOutSidePadding = 0.dp, + floatingCornerRadius = 0.dp, + floatingShadowElevation = 0.dp, + floatingWindowInsetsPadding = false, + floatingContainerWidth = 0.dp, + floatingContainerHeight = 0.dp, + floatingIconSize = 0.dp, + floatingItemHorizontalPadding = 0.dp, + floatingStrokeWidth = 0.dp, + bottomShowDivider = false, + bottomWindowInsetsPadding = false, + bottomContainerHeight = 56.dp, + bottomIconSize = 22.dp, + bottomItemHorizontalPadding = 0.dp, + bottomShowLabel = true, + unselectedAlpha = 0.4f, + ) + } + val activeNavStyleState = if (settingsState.useFloatingNavigationBar) { + capsuleNavStyleState + } else { + bottomNavStyleState + } + + var isAppsSearchExpanded by remember { mutableStateOf(false) } + var isBlacklistSearchExpanded by remember { mutableStateOf(false) } val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route + + val appListState = rememberLazyListState() + + val scope = rememberCoroutineScope() + val selectedIndex = items.indexOfFirst { it.route == currentRoute }.coerceAtLeast(0) + val onPrimaryDestinationClick: (TopLevelDestination) -> Unit = { destination -> + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + val topBarTitle = if (currentRoute?.startsWith("channel_settings/") == true) { + Uri.decode(backStackEntry?.arguments?.getString("channelName").orEmpty()) + .ifBlank { "渠道详情" } + } else { + routeTitle(currentRoute) + } val isSecondaryRoute = currentRoute?.startsWith("app_channels/") == true || + currentRoute?.startsWith("channel_settings/") == true || currentRoute == "blacklist" || currentRoute == "ai_config" - val scrollBehavior = MiuixScrollBehavior( + val secondaryTopBarStyleState = remember { + TopBarStyleState( + variant = TopBarVariant.Secondary, + defaultWindowInsetsPadding = false, + ) + } + val homeTopBarStyleState = remember { + TopBarStyleState( + variant = TopBarVariant.PrimaryHome, + defaultWindowInsetsPadding = false, + ) + } + val appsTopBarStyleState = remember { + TopBarStyleState( + variant = TopBarVariant.PrimaryApps, + defaultWindowInsetsPadding = false, + ) + } + val settingsTopBarStyleState = remember { + TopBarStyleState( + variant = TopBarVariant.PrimarySettings, + defaultWindowInsetsPadding = false, + ) + } + val activeTopBarStyleState = when { + isSecondaryRoute -> secondaryTopBarStyleState + currentRoute == "apps" -> appsTopBarStyleState + currentRoute == "settings" -> settingsTopBarStyleState + else -> homeTopBarStyleState + } + fun dismissTransientUi(): Boolean { + return when { + showAppsMenu -> { + showAppsMenu = false + true + } + showAppChannelsMenu -> { + showAppChannelsMenu = false + true + } + showBlacklistMenu -> { + showBlacklistMenu = false + true + } + showRestartDialog -> { + showRestartDialog = false + true + } + showSponsorDialog -> { + showSponsorDialog = false + true + } + isAppsSearchExpanded -> { + isAppsSearchExpanded = false + appsVm.setQuery("") + true + } + isBlacklistSearchExpanded -> { + isBlacklistSearchExpanded = false + blacklistVm.setQuery("") + true + } + else -> false + } + } + val shouldHandleBack = showAppsMenu || showAppChannelsMenu || showBlacklistMenu || showRestartDialog || showSponsorDialog || isAppsSearchExpanded || isBlacklistSearchExpanded + val homeScrollBehavior = MiuixScrollBehavior( state = rememberTopAppBarState(), - canScroll = { true }, + canScroll = { !popupShowing }, + ) + val appsScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { !popupShowing }, + ) + val settingsScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { !popupShowing }, + ) + val secondaryScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { !popupShowing }, ) + val activePrimaryScrollBehavior = when (currentRoute) { + "apps" -> appsScrollBehavior + "settings" -> settingsScrollBehavior + else -> homeScrollBehavior + } + val activeTopBarScrollBehavior = if (isSecondaryRoute) { + secondaryScrollBehavior + } else { + activePrimaryScrollBehavior + } + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - MiuixScaffold( - snackbarHost = { MiuixSnackbarHost(snackbarHostState) }, - topBar = { - if (isSecondaryRoute) { - MiuixSmallTopAppBar( - title = routeTitle(currentRoute), - navigationIcon = { - MiuixIconButton(onClick = { navController.popBackStack() }) { - Icon( - imageVector = MiuixIcons.Basic.ArrowRight, - contentDescription = "返回", - modifier = Modifier.rotate(180f), - ) - } - }, - modifier = Modifier, - scrollBehavior = null, - defaultWindowInsetsPadding = false, + BackHandler(enabled = shouldHandleBack) { + dismissTransientUi() + } + val layoutDirection = LocalLayoutDirection.current + val insets = WindowInsets.navigationBars.asPaddingValues() + val isAppsRoute = currentRoute == "apps" + val topBarCollapseProgress by remember( + currentRoute, + activeTopBarScrollBehavior.state, + ) { + derivedStateOf { + activeTopBarScrollBehavior.state.collapsedFraction.coerceIn(0f, 1f) + } + } + val isAppsLargeTitleExpanded by remember(currentRoute, appsScrollBehavior.state) { + derivedStateOf { + currentRoute == "apps" && appsScrollBehavior.state.collapsedFraction < 0.98f + } + } + + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner.provides(navigationEventDispatcherOwner), + ) { + MiuixScaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + val topBarScrollBehavior = activeTopBarScrollBehavior + + val isAppsRoute = currentRoute == "apps" + val isBlacklistRoute = currentRoute == "blacklist" + val showTopBarExtraContent = + (isAppsRoute && (appsSelectionMode || isAppsSearchExpanded)) || + isBlacklistRoute + val searchExpandTransitionMs = 260 + val collapseProgress = topBarCollapseProgress + val baseExpandedAlpha = when { + isAppsRoute && showTopBarExtraContent -> 0.72f + activeTopBarStyleState.variant == TopBarVariant.PrimaryHome -> 0.62f + else -> 0.68f + } + val baseCollapsedAlpha = when { + activeTopBarStyleState.variant == TopBarVariant.Secondary -> 0.88f + isAppsRoute && showTopBarExtraContent -> 0.9f + else -> 0.86f + } + val topBarSurfaceAlpha = androidx.compose.ui.util.lerp( + baseExpandedAlpha, + baseCollapsedAlpha, + collapseProgress, ) - } else { - MiuixTopAppBar( - title = routeTitle(currentRoute), - largeTitle = routeLargeTitle(currentRoute), - actions = { - if (currentRoute == "home") { - MiuixIconButton(onClick = { openExternalUrl(context, DOCUMENTATION_URL) }) { - Icon( - imageVector = Icons.Filled.InsertDriveFile, - contentDescription = "文档", - ) - } - MiuixIconButton(onClick = { showSponsorDialog = true }) { - Icon( - imageVector = Icons.Filled.Favorite, - contentDescription = "赞助", - ) + val topBarBlurRadius = androidx.compose.ui.util.lerp( + 16f, + if (activeTopBarStyleState.variant == TopBarVariant.Secondary) 22f else 26f, + collapseProgress, + ) + val topBarBackgroundColor = lerp( + MaterialTheme.colorScheme.surface.copy(alpha = 0f), + MaterialTheme.colorScheme.surface.copy(alpha = topBarSurfaceAlpha), + collapseProgress, + ) + val shouldBlurTopBar = collapseProgress > 0.02f + + Box( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .matchParentSize() + .let { + if (backdrop != null) { + it.textureBlur( + backdrop, + shape = RectangleShape, + blurRadiusX = topBarBlurRadius, + blurRadiusY = topBarBlurRadius, + ) + } else it } - MiuixIconButton(onClick = { showRestartDialog = true }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "重启作用域", - ) + .alpha(if (shouldBlurTopBar) 1f else 0f) + .background(topBarBackgroundColor) + ) + Column(modifier = Modifier.fillMaxWidth()) { + MiuixTopAppBar( + title = topBarTitle, + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + navigationIcon = { + when (activeTopBarStyleState.variant) { + TopBarVariant.Secondary -> { + MiuixIconButton(onClick = { + backDispatcher?.onBackPressed() ?: run { + val dismissed = dismissTransientUi() + if (!dismissed) { + navController.popBackStack() + } + } + }) { + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = "返回", + modifier = Modifier.rotate(180f), + ) + } + } + + TopBarVariant.PrimaryApps -> { + if (appsSelectionMode) { + MiuixIconButton(onClick = { appsExitSelectionRequestId += 1 }) { + FaIcon( + glyph = FaGlyph.Times, + contentDescription = "退出多选", + ) + } + } else { + Spacer(modifier = Modifier.size(40.dp)) + } + } + + else -> Spacer(modifier = Modifier.size(40.dp)) } - } else if (currentRoute == "apps") { - Box { - MiuixIconButton(onClick = { showAppsMenu = true }) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = "更多操作", - ) + }, + actions = { + when (activeTopBarStyleState.variant) { + TopBarVariant.Secondary -> { + if (currentRoute?.startsWith("app_channels/") == true) { + Box { + BackHandler(enabled = showAppChannelsMenu) { + showAppChannelsMenu = false + } + MiuixIconButton(onClick = { showAppChannelsMenu = true }) { + Icon( + imageVector = MiuixIcons.Regular.MoreCircle, + contentDescription = "渠道页更多操作", + ) + } + OverlayListPopup( + show = showAppChannelsMenu, + alignment = MiuixPopupPositionProvider.Align.End, + onDismissRequest = { showAppChannelsMenu = false }, + onDismissFinished = {}, + ) { + val menuItems = listOf( + "启用全部渠道" to { + showAppChannelsMenu = false + appChannelsEnableAllRequestId += 1 + }, + "批量设置渠道配置" to { + showAppChannelsMenu = false + appChannelsBatchRequestId += 1 + }, + ) + MiuixListPopupColumn { + menuItems.forEachIndexed { index, (title, action) -> + MiuixDropdownImpl( + text = title, + optionSize = menuItems.size, + isSelected = false, + onSelectedIndexChange = { action() }, + index = index, + ) + } + } + } + } + } else if (currentRoute == "blacklist") { + MiuixIconButton(onClick = { isBlacklistSearchExpanded = !isBlacklistSearchExpanded }) { + Icon( + imageVector = MiuixIcons.Regular.Search, + contentDescription = "搜索", + ) + } + Box { + BackHandler(enabled = showBlacklistMenu) { + showBlacklistMenu = false + } + MiuixIconButton(onClick = { showBlacklistMenu = true }) { + Icon( + imageVector = MiuixIcons.Regular.MoreCircle, + contentDescription = "黑名单页更多操作", + ) + } + OverlayListPopup( + show = showBlacklistMenu, + alignment = MiuixPopupPositionProvider.Align.End, + onDismissRequest = { showBlacklistMenu = false }, + onDismissFinished = {}, + ) { + val menuItems = listOf( + "游戏预设" to { + showBlacklistMenu = false + blacklistVm.applyGamePreset() + }, + "全部加入" to { + showBlacklistMenu = false + blacklistVm.enableAllVisible() + }, + "全部移除" to { + showBlacklistMenu = false + blacklistVm.disableAllVisible() + }, + (if (blacklistState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + showBlacklistMenu = false + blacklistVm.setShowSystemApps(!blacklistState.showSystemApps) + }, + "刷新" to { + showBlacklistMenu = false + blacklistVm.refresh() + }, + ) + MiuixListPopupColumn { + menuItems.forEachIndexed { index, (title, action) -> + MiuixDropdownImpl( + text = title, + optionSize = menuItems.size, + isSelected = false, + onSelectedIndexChange = { action() }, + index = index, + ) + } + } + } + } + } } - MiuixOverlayListPopup( - show = showAppsMenu, - alignment = MiuixPopupPositionProvider.Align.Start, - onDismissRequest = { showAppsMenu = false }, - onDismissFinished = {}, - ) { - MiuixListPopupColumn { - OverlayListPopupMenuItem(if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") { - showAppsMenu = false - appsVm.setShowSystemApps(!appsState.showSystemApps) + + TopBarVariant.PrimaryHome -> { + MiuixIconButton(onClick = { openExternalUrl(context, DOCUMENTATION_URL) }) { + Icon( + imageVector = MiuixIcons.Regular.Info, + contentDescription = "文档", + ) + } + MiuixIconButton(onClick = { showSponsorDialog = true }) { + Icon( + imageVector = MiuixIcons.Regular.Create, + contentDescription = "赞助", + ) + } + MiuixIconButton(onClick = { showRestartDialog = true }) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "重启作用域", + ) + } + } + + TopBarVariant.PrimaryApps -> { + MiuixIconButton(onClick = { isAppsSearchExpanded = !isAppsSearchExpanded }) { + Icon( + imageVector = MiuixIcons.Regular.Search, + contentDescription = "搜索", + ) + } + if (!appsSelectionMode) { + MiuixIconButton(onClick = { appsSelectionRequestId += 1 }) { + Icon( + imageVector = MiuixIcons.Regular.SelectAll, + contentDescription = "进入多选", + ) } - OverlayListPopupMenuItem("全局批量应用") { + } + Box { + BackHandler(enabled = showAppsMenu) { showAppsMenu = false - appsBatchRequestId += 1 } - OverlayListPopupMenuItem("刷新") { - showAppsMenu = false - appsVm.refresh() + MiuixIconButton(onClick = { showAppsMenu = true }) { + Icon( + imageVector = MiuixIcons.Regular.MoreCircle, + contentDescription = "更多操作", + ) + } + OverlayListPopup( + show = showAppsMenu, + alignment = MiuixPopupPositionProvider.Align.End, + onDismissRequest = { showAppsMenu = false }, + onDismissFinished = {}, + ) { + val menuItems = if (appsSelectionMode) { + listOf( + (if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + showAppsMenu = false + appsVm.setShowSystemApps(!appsState.showSystemApps) + }, + "开启已选" to { + showAppsMenu = false + appsEnableSelectedRequestId += 1 + }, + "关闭已选" to { + showAppsMenu = false + appsDisableSelectedRequestId += 1 + }, + "选中已启用" to { + showAppsMenu = false + appsSelectEnabledRequestId += 1 + }, + "批量设置渠道配置" to { + showAppsMenu = false + appsBatchSelectedRequestId += 1 + }, + ) + } else { + listOf( + (if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + showAppsMenu = false + appsVm.setShowSystemApps(!appsState.showSystemApps) + }, + "一键开启全部" to { + showAppsMenu = false + appsEnableAllRequestId += 1 + }, + "一键关闭全部" to { + showAppsMenu = false + appsDisableAllRequestId += 1 + }, + "刷新" to { + showAppsMenu = false + appsVm.refresh() + }, + ) + } + MiuixListPopupColumn { + menuItems.forEachIndexed { index, (title, action) -> + MiuixDropdownImpl( + text = title, + optionSize = menuItems.size, + isSelected = false, + onSelectedIndexChange = { action() }, + index = index, + ) + } + } } } } + + TopBarVariant.PrimarySettings -> Unit } - } - }, - modifier = Modifier, - scrollBehavior = scrollBehavior, - defaultWindowInsetsPadding = false, - ) - } - }, - bottomBar = { - if (!isSecondaryRoute) { - val selectedIndex = items.indexOfFirst { it.route == currentRoute }.coerceAtLeast(0) - MiuixNavigationBar( - modifier = Modifier.height(62.dp), - showDivider = false, - defaultWindowInsetsPadding = false, - ) { - items.forEachIndexed { index, destination -> - MiuixNavigationBarItem( - selected = index == selectedIndex, - onClick = { - navController.navigate(destination.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + }, + scrollBehavior = topBarScrollBehavior, + defaultWindowInsetsPadding = activeTopBarStyleState.defaultWindowInsetsPadding, + ) + + if (isAppsRoute) { + if (isAppsSearchExpanded || appsSelectionMode) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + ) { + AnimatedVisibility( + visible = isAppsSearchExpanded, + enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + + expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)) + + shrinkVertically(animationSpec = tween(durationMillis = 220)), + label = "apps_search_bar_visibility", + ) { + MiuixTextField( + value = appsState.query, + onValueChange = appsVm::setQuery, + label = "搜索应用 / 包名", + useLabelAsPlaceholder = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + AnimatedVisibility( + visible = appsSelectionMode, + enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + + expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)) + + shrinkVertically(animationSpec = tween(durationMillis = 220)), + label = "apps_selection_info_visibility", + ) { + val visiblePackages = appsState.filteredApps.map { it.packageName }.toSet() + val allVisibleSelected = visiblePackages.isNotEmpty() && visiblePackages.all { appsState.selectedPackages.contains(it) } + val selectAllState = if (allVisibleSelected) ToggleableState.On else if (appsState.selectedPackages.isNotEmpty()) ToggleableState.Indeterminate else ToggleableState.Off + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("已选择 ${appsState.selectedPackages.size} 项", style = MaterialTheme.typography.bodySmall) + MiuixCheckbox( + state = selectAllState, + onClick = { + if (allVisibleSelected) { + appsVm.setSelectedPackages(appsState.selectedPackages - visiblePackages) + } else { + appsVm.setSelectedPackages(appsState.selectedPackages + visiblePackages) + } + } + ) } - launchSingleTop = true - restoreState = true } - }, - icon = destination.icon, - label = destination.label, - ) - } - } - } - }, - ) { innerPadding -> - NavHost( - navController = navController, - startDestination = "home", - enterTransition = { - val from = mainRouteIndex(initialState.destination.route) - val to = mainRouteIndex(targetState.destination.route) - if (from >= 0 && to >= 0 && from != to) { - if (to > from) { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(260), - ) - } else { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(260), - ) - } - } else { - EnterTransition.None - } - }, - exitTransition = { - val from = mainRouteIndex(initialState.destination.route) - val to = mainRouteIndex(targetState.destination.route) - if (from >= 0 && to >= 0 && from != to) { - if (to > from) { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(260), - ) - } else { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(260), - ) + } + } } - } else { - ExitTransition.None - } - }, - popEnterTransition = { - val from = mainRouteIndex(initialState.destination.route) - val to = mainRouteIndex(targetState.destination.route) - if (from >= 0 && to >= 0 && from != to) { - if (to > from) { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(260), - ) - } else { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(260), - ) + + if (isBlacklistRoute) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + ) { + AnimatedVisibility( + visible = isBlacklistSearchExpanded, + enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + + expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)) + + shrinkVertically(animationSpec = tween(durationMillis = 220)), + label = "blacklist_search_bar_visibility", + ) { + MiuixTextField( + value = blacklistState.query, + onValueChange = blacklistVm::setQuery, + label = "搜索应用 / 包名", + useLabelAsPlaceholder = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + Text( + text = "已加入黑名单 ${blacklistState.blacklistedPackages.size} 项", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } - } else { - EnterTransition.None } - }, - popExitTransition = { - val from = mainRouteIndex(initialState.destination.route) - val to = mainRouteIndex(targetState.destination.route) - if (from >= 0 && to >= 0 && from != to) { - if (to > from) { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(260), - ) - } else { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(260), - ) - } - } else { - ExitTransition.None } }, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - composable("home") { - val uiState by homeVm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - homeVm.events.collect { snackbarHostState.showSnackbar(it) } - } - HomeScreen( - uiState = uiState, - onRefresh = homeVm::refreshStatus, - onSendTest = homeVm::sendTest, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) - } - composable("apps") { - LaunchedEffect(Unit) { - appsVm.events.collect { snackbarHostState.showSnackbar(it) } - } - AppsScreen( - state = appsState, - onRefresh = appsVm::refresh, - onQueryChange = appsVm::setQuery, - onAppEnabledChange = appsVm::setEnabled, - onOpenAppChannels = { pkg -> navController.navigate("app_channels/$pkg") }, - onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, - batchRequestId = appsBatchRequestId, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) - } - composable("settings") { - val vm: SettingsViewModel = viewModel() - val uiState by vm.uiState.collectAsStateWithLifecycle() - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - ) { uri -> - if (uri != null) { - vm.importConfigFromUri(uri) - } - } - LaunchedEffect(Unit) { - vm.events.collect { snackbarHostState.showSnackbar(it) } - } - SettingsScreen( - state = uiState, - onToggle = vm::updateSwitch, - onMarqueeSpeed = vm::updateMarqueeSpeed, - onBigIslandWidth = vm::updateBigIslandMaxWidth, - onThemeModeChange = vm::updateThemeMode, - onLocaleChange = vm::updateLocale, - onHideDesktopIcon = vm::setDesktopIconHidden, - onOpenBlacklist = { navController.navigate("blacklist") }, - onOpenAiConfig = { navController.navigate("ai_config") }, - onCheckUpdate = { openExternalUrl(context, GITHUB_RELEASE_URL) }, - onOpenGithub = { openExternalUrl(context, GITHUB_REPO_URL) }, - onExportToFile = vm::exportConfigToFile, - onPickImportFile = { importLauncher.launch(arrayOf("application/json", "text/plain")) }, - onExportToClipboard = vm::exportConfigToClipboard, - onImportFromClipboard = vm::importConfigFromClipboard, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) - } - composable( - route = "app_channels/{packageName}", - arguments = listOf(navArgument("packageName") { type = NavType.StringType }), - ) { backStack -> - val vm: AppChannelsViewModel = viewModel() - val state by vm.uiState.collectAsStateWithLifecycle() - val packageNameArg = backStack.arguments?.getString("packageName").orEmpty() - LaunchedEffect(packageNameArg) { - vm.setPackageNameIfEmpty(packageNameArg) - } - AppChannelsScreen( - state = state, - onRefresh = vm::refresh, - onToggleChannel = vm::toggleChannel, - onEnableAllChannels = vm::enableAllChannels, - onCycleTemplate = vm::cycleTemplate, - onSetTimeout = vm::setTimeout, - onCycleSetting = vm::cycleSetting, - onSetHighlightColor = vm::setHighlightColor, - onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, - ) - } - composable("blacklist") { - val vm: BlacklistViewModel = viewModel() - val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.events.collect { snackbarHostState.showSnackbar(it) } + bottomBar = {}, // Handle bottom bar as overlay so list draws beneath it for `.textureBlur` + ) { innerPadding -> + val bottomOverlayPaddingTarget = when { + isSecondaryRoute -> 0.dp + activeNavStyleState.floating -> { + activeNavStyleState.floatingBottomOffset + + activeNavStyleState.floatingContainerHeight + + if (activeNavStyleState.floatingWindowInsetsPadding) { + insets.calculateBottomPadding() + } else { + 0.dp + } } - BlacklistScreen( - state = state, - onRefresh = vm::refresh, - onQueryChange = vm::setQuery, - onShowSystemChange = vm::setShowSystemApps, - onSetBlacklisted = vm::setBlacklisted, - onEnableAllVisible = vm::enableAllVisible, - onDisableAllVisible = vm::disableAllVisible, - onApplyGamePreset = vm::applyGamePreset, - ) - } - composable("ai_config") { - val vm: AiConfigViewModel = viewModel() - val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.events.collect { snackbarHostState.showSnackbar(it) } + else -> { + activeNavStyleState.bottomContainerHeight + + if (activeNavStyleState.bottomWindowInsetsPadding) { + insets.calculateBottomPadding() + } else { + 0.dp + } } - AiConfigScreen( - state = state, - onUpdate = vm::setState, - onSave = vm::save, - onTest = vm::testConnection, - ) } - } - if (showSponsorDialog) { - SponsorDialog( - show = true, + val bottomOverlayPadding by animateDpAsState( + targetValue = bottomOverlayPaddingTarget, + animationSpec = tween(durationMillis = 320), + label = "bottom_overlay_padding_transition", + ) + val combinedPadding = PaddingValues( + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + bottomOverlayPadding + ) + Box( + modifier = Modifier + .fillMaxSize() + ) { + // Background content capturing box + Box( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .consumeWindowInsets(combinedPadding), + ) { + CompositionLocalProvider(LocalContentPadding provides combinedPadding) { + NavHost( + navController = navController, + startDestination = "home", + enterTransition = { + val direction = resolveRouteSlideDirection( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + ) + if (direction != null) { + slideIntoContainer( + direction, + animationSpec = tween(260), + ) + } else { + EnterTransition.None + } + }, + exitTransition = { + val direction = resolveRouteSlideDirection( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + ) + if (direction != null) { + slideOutOfContainer( + direction, + animationSpec = tween(260), + ) + } else { + ExitTransition.None + } + }, + popEnterTransition = { + val direction = resolveRouteSlideDirection( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + ) + if (direction != null) { + slideIntoContainer( + direction, + animationSpec = tween(260), + ) + } else { + EnterTransition.None + } + }, + popExitTransition = { + val direction = resolveRouteSlideDirection( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + ) + if (direction != null) { + slideOutOfContainer( + direction, + animationSpec = tween(260), + ) + } else { + ExitTransition.None + } + }, + modifier = Modifier.fillMaxSize(), + ) { + composable("home") { + val uiState by homeVm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + homeVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + HomeScreen( + uiState = uiState, + onRefresh = homeVm::refreshStatus, + onSendTest = homeVm::sendTest, + modifier = Modifier.nestedScroll(homeScrollBehavior.nestedScrollConnection), + ) + } + composable("apps") { + LaunchedEffect(Unit) { + appsVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + AppsScreen( + state = appsState, + onRefresh = appsVm::refresh, + onQueryChange = appsVm::setQuery, + onAppEnabledChange = appsVm::setEnabled, + onAppSelectedChange = appsVm::toggleSelectedPackage, + onSelectAll = appsVm::setSelectedPackages, + onOpenAppChannels = { pkg -> navController.navigate("app_channels/$pkg") }, + onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, + onBatchApplySelected = appsVm::batchApplyToSelectedApps, + onSelectionModeChanged = { appsSelectionMode = it }, + + appListState = appListState, + selectionRequestId = appsSelectionRequestId, + exitSelectionRequestId = appsExitSelectionRequestId, + enableSelectedRequestId = appsEnableSelectedRequestId, + disableSelectedRequestId = appsDisableSelectedRequestId, + selectEnabledRequestId = appsSelectEnabledRequestId, + batchSelectedRequestId = appsBatchSelectedRequestId, + enableAllRequestId = appsEnableAllRequestId, + disableAllRequestId = appsDisableAllRequestId, + batchRequestId = appsBatchRequestId, + topAppBarScrollBehavior = appsScrollBehavior, + canPullToRefresh = true, + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), + ) + } + composable("settings") { + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + settingsVm.importConfigFromUri(uri) + } + } + LaunchedEffect(Unit) { + settingsVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + SettingsScreen( + state = settingsState, + onToggle = settingsVm::updateSwitch, + onMarqueeSpeed = settingsVm::updateMarqueeSpeed, + onBigIslandWidth = settingsVm::updateBigIslandMaxWidth, + onThemeModeChange = settingsVm::updateThemeMode, + onLocaleChange = settingsVm::updateLocale, + onHideDesktopIcon = settingsVm::setDesktopIconHidden, + onOpenBlacklist = { navController.navigate("blacklist") }, + onOpenAiConfig = { navController.navigate("ai_config") }, + onCheckUpdate = { openExternalUrl(context, GITHUB_RELEASE_URL) }, + onOpenGithub = { openExternalUrl(context, GITHUB_REPO_URL) }, + onExportToFile = settingsVm::exportConfigToFile, + onPickImportFile = { importLauncher.launch(arrayOf("application/json", "text/plain")) }, + onExportToClipboard = settingsVm::exportConfigToClipboard, + onImportFromClipboard = settingsVm::importConfigFromClipboard, + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), + ) + } + composable( + route = "app_channels/{packageName}", + arguments = listOf(navArgument("packageName") { type = NavType.StringType }), + ) { backStack -> + val vm: AppChannelsViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + val packageNameArg = backStack.arguments?.getString("packageName").orEmpty() + LaunchedEffect(packageNameArg) { + vm.setPackageNameIfEmpty(packageNameArg) + } + AppChannelsScreen( + state = state, + onRefresh = vm::refresh, + onSetAppEnabled = vm::setAppEnabled, + onToggleChannel = vm::toggleChannel, + onEnableAllChannels = vm::enableAllChannels, + onOpenChannelSettings = { channelId, channelName -> + navController.navigate( + "channel_settings/${Uri.encode(packageNameArg)}/${Uri.encode(channelId)}/${Uri.encode(channelName)}", + ) + }, + onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, + enableAllRequestId = appChannelsEnableAllRequestId, + batchRequestId = appChannelsBatchRequestId, + modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + ) + } + composable( + route = "channel_settings/{packageName}/{channelId}/{channelName}", + arguments = listOf( + navArgument("packageName") { type = NavType.StringType }, + navArgument("channelId") { type = NavType.StringType }, + navArgument("channelName") { type = NavType.StringType }, + ), + ) { backStack -> + val vm: AppChannelsViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + val packageNameArg = Uri.decode( + backStack.arguments?.getString("packageName").orEmpty(), + ) + val channelIdArg = Uri.decode( + backStack.arguments?.getString("channelId").orEmpty(), + ) + LaunchedEffect(packageNameArg) { + vm.setPackageNameIfEmpty(packageNameArg) + } + ChannelSettingsScreen( + state = state, + channelId = channelIdArg, + onRefresh = vm::refresh, + onSetTemplate = { vm.setTemplate(channelIdArg, it) }, + onSetTimeout = { vm.setTimeout(channelIdArg, it) }, + onSetSetting = { setting, value -> vm.setSetting(channelIdArg, setting, value) }, + onSetHighlightColor = { vm.setHighlightColor(channelIdArg, it) }, + modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + ) + } + composable("blacklist") { + LaunchedEffect(Unit) { + blacklistVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + BlacklistScreen( + state = blacklistState, + onRefresh = blacklistVm::refresh, + onQueryChange = blacklistVm::setQuery, + onSetBlacklisted = blacklistVm::setBlacklisted, + canPullToRefresh = false, + modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + ) + } + composable("ai_config") { + val vm: AiConfigViewModel = viewModel() + val uiState by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + AiConfigScreen( + state = uiState, + onUpdate = vm::setState, + onSave = vm::save, + onTest = vm::testConnection, + modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + ) + } + } + } + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(combinedPadding.calculateTopPadding() + 72.dp) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = androidx.compose.ui.util.lerp(0.18f, 0.56f, topBarCollapseProgress)), + MaterialTheme.colorScheme.surface.copy(alpha = androidx.compose.ui.util.lerp(0.08f, 0.32f, topBarCollapseProgress)), + Color.Transparent, + ), + ), + ), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(insets.calculateBottomPadding() + 96.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface.copy(alpha = 0.24f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.46f), + ), + ), + ), + ) + } // End of layerBackdrop box + + if (!isSecondaryRoute) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.BottomCenter, + ) { + HyperCeilerNavigationSwitchBar( + items = items, + selectedIndex = selectedIndex, + style = activeNavStyleState, + onDestinationClick = onPrimaryDestinationClick, + backdrop = backdrop, + ) + } + } + } + SponsorDialog( + show = showSponsorDialog, onDismiss = { showSponsorDialog = false }, ) - } - if (showRestartDialog) { RestartScopeDialog( - show = true, + show = showRestartDialog, onDismiss = { showRestartDialog = false }, onConfirm = { systemUi, downloads, xmsf -> showRestartDialog = false @@ -585,15 +1617,28 @@ private fun HomeScreen( onSendTest: () -> Unit, modifier: Modifier = Modifier, ) { + val notes = listOf( + "1.此页面仅用于测试是否支持超级岛,并不代表实际效果", + "2.请在 HyperCeiler 中关闭系统界面和小米服务框架的焦点通知白名单", + "3.LSPosed 管理器中激活后,必须重启相关作用域软件", + "4.支持通用适配,自行勾选合适的模板尝试", + ) + val contentPadding = LocalContentPadding.current LazyColumn( modifier = modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .overScrollVertical() + .scrollEndHaptic() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding() + 6.dp, + bottom = contentPadding.calculateBottomPadding() + 6.dp + ), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { item { MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("模块状态", style = MaterialTheme.typography.titleMedium) val statusText = when (uiState.moduleActive) { null -> "检测中..." @@ -607,7 +1652,7 @@ private fun HomeScreen( } } item { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { MiuixButton(onClick = onRefresh, modifier = Modifier.weight(1f)) { Text("刷新状态") } @@ -616,6 +1661,41 @@ private fun HomeScreen( } } } + item { + MiuixSmallTitle(text = "注意事项") + } + item { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + notes.forEach { text -> + val dotIndex = text.indexOf('.') + val indexText = if (dotIndex > 0) text.substring(0, dotIndex + 1) else "" + val contentText = if (dotIndex > 0) text.substring(dotIndex + 1) else text + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = indexText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(22.dp), + ) + Text( + text = contentText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } } } @@ -629,18 +1709,22 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { } }.getOrNull() } - MiuixOverlayDialog( + OverlayDialog( show = show, title = "赞助支持", - summary = "", + summary = "赞助作者", onDismissRequest = onDismiss, onDismissFinished = {}, + renderInRootScaffold = false, ) { Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .overScrollVertical() + .scrollEndHaptic(), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("赞助作者") if (qrBitmap != null) { Image( bitmap = qrBitmap.asImageBitmap(), @@ -650,7 +1734,10 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { } else { Text("未找到赞助图片 assets/images/wechat.jpg") } - MiuixButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + MiuixButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { Text("关闭") } } @@ -667,25 +1754,74 @@ private fun RestartScopeDialog( var restartDownloads by remember { mutableStateOf(true) } var restartXmsf by remember { mutableStateOf(true) } - MiuixOverlayBottomSheet( + val allSelected = restartSystemUi && restartDownloads && restartXmsf + + OverlayDialog( show = show, title = "选择需要重启的进程", onDismissRequest = onDismiss, onDismissFinished = {}, + renderInRootScaffold = false, ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - ScopeCheckboxRow("SystemUI(com.android.systemui)", restartSystemUi) { restartSystemUi = !restartSystemUi } - ScopeCheckboxRow("下载管理(com.android.providers.downloads)", restartDownloads) { restartDownloads = !restartDownloads } - ScopeCheckboxRow("XMSF(com.xiaomi.xmsf)", restartXmsf) { restartXmsf = !restartXmsf } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { - Text("取消") + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .overScrollVertical() + .scrollEndHaptic(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp, + ), + ) { + ScopeCheckboxRow( + title = "系统界面", + subtitle = "com.android.systemui", + checked = restartSystemUi, + ) { restartSystemUi = !restartSystemUi } + ScopeCheckboxRow( + title = "下载管理", + subtitle = "com.android.providers.downloads", + checked = restartDownloads, + ) { restartDownloads = !restartDownloads } + ScopeCheckboxRow( + title = "小米服务框架", + subtitle = "com.xiaomi.xmsf", + checked = restartXmsf, + ) { restartXmsf = !restartXmsf } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MiuixButton( + onClick = { + val nextChecked = !allSelected + restartSystemUi = nextChecked + restartDownloads = nextChecked + restartXmsf = nextChecked + }, + modifier = Modifier.weight(1f), + ) { + Text(if (allSelected) "全不选" else "全选") } MiuixButton( onClick = { onConfirm(restartSystemUi, restartDownloads, restartXmsf) }, modifier = Modifier.weight(1f), + colors = MiuixButtonDefaults.buttonColorsPrimary(), ) { - Text("重启") + Text( + text = "确定", + color = MiuixTheme.colorScheme.onPrimary, + ) } } } @@ -693,13 +1829,26 @@ private fun RestartScopeDialog( } @Composable -private fun ScopeCheckboxRow(label: String, checked: Boolean, onClick: () -> Unit) { +private fun ScopeCheckboxRow(title: String, subtitle: String, checked: Boolean, onClick: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .pressable(interactionSource = remember { MutableInteractionSource() }) + .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(label, modifier = Modifier.weight(1f)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } MiuixCheckbox( state = if (checked) ToggleableState.On else ToggleableState.Off, onClick = onClick, @@ -727,303 +1876,494 @@ private fun SettingsScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current - var showThemeDialog by remember { mutableStateOf(false) } - var showLanguageDialog by remember { mutableStateOf(false) } - - if (showThemeDialog) { - MiuixOverlayDialog( - show = true, - title = "颜色模式", - summary = "", - onDismissRequest = { showThemeDialog = false }, - onDismissFinished = {}, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ThemeOption("跟随系统", state.themeMode == "system") { - onThemeModeChange("system") - showThemeDialog = false - } - ThemeOption("浅色", state.themeMode == "light") { - onThemeModeChange("light") - showThemeDialog = false - } - ThemeOption("深色", state.themeMode == "dark") { - onThemeModeChange("dark") - showThemeDialog = false - } - } - } - } + val themeModeOptions = listOf( + "system" to "跟随系统", + "light" to "浅色", + "dark" to "深色", + ) + val selectedThemeIndex = themeModeOptions.indexOfFirst { it.first == state.themeMode }.coerceAtLeast(0) - if (showLanguageDialog) { - MiuixOverlayDialog( - show = true, - title = "语言", - summary = "", - onDismissRequest = { showLanguageDialog = false }, - onDismissFinished = {}, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ThemeOption("跟随系统", state.locale == null) { - onLocaleChange(null) - showLanguageDialog = false - } - ThemeOption("中文", state.locale == "zh") { - onLocaleChange("zh") - showLanguageDialog = false - } - ThemeOption("English", state.locale == "en") { - onLocaleChange("en") - showLanguageDialog = false - } - ThemeOption("日本語", state.locale == "ja") { - onLocaleChange("ja") - showLanguageDialog = false - } - ThemeOption("Türkçe", state.locale == "tr") { - onLocaleChange("tr") - showLanguageDialog = false - } - } - } - } + val localeOptions = listOf( + "__system__" to "跟随系统", + "zh" to "中文", + "en" to "English", + "ja" to "日本語", + "tr" to "Türkçe", + ) + val localeValue = state.locale ?: "__system__" + val selectedLocaleIndex = localeOptions.indexOfFirst { it.first == localeValue }.coerceAtLeast(0) - val themeModeText = when (state.themeMode) { - "light" -> "浅色" - "dark" -> "深色" - else -> "跟随系统" - } - val languageText = when (state.locale) { - "zh" -> "中文" - "en" -> "English" - "ja" -> "日本語" - "tr" -> "Türkçe" - else -> "跟随系统" - } + val contentPadding = LocalContentPadding.current LazyColumn( modifier = modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .overScrollVertical() + .scrollEndHaptic() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding() + 6.dp, + bottom = contentPadding.calculateBottomPadding() + 6.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item { SectionTitle("AI 增强") } item { - SettingsEntryItem( - title = "AI 通知摘要", - subtitle = if (state.aiEnabled) "已启用 · 点击配置 AI 参数" else "已关闭 · 点击进行配置", - onClick = onOpenAiConfig, - ) + SectionTitle("AI 增强") + SettingsGroupCard { + SettingsEntryItem( + title = "AI 通知摘要", + subtitle = if (state.aiEnabled) "已启用 · 点击配置 AI 参数" else "已关闭 · 点击进行配置", + onClick = onOpenAiConfig, + ) + } } - item { SectionTitle("通知黑名单") } item { - SettingsEntryItem( - title = "通知黑名单", - subtitle = "启动黑名单应用时,停用焦点通知的自动展开功能", - onClick = onOpenBlacklist, - ) + SectionTitle("通知黑名单") + SettingsGroupCard { + SettingsEntryItem( + title = "通知黑名单", + subtitle = "启动黑名单应用时,停用焦点通知的自动展开功能", + onClick = onOpenBlacklist, + ) + } } - item { SectionTitle("行为") } - item { - ToggleItem( - "交互触感", - "为开关、滑块和按钮启用 Hyper 定制震感反馈", - state.interactionHaptics, - ) { onToggle(PrefKeys.INTERACTION_HAPTICS, it) } - } item { - ToggleItem( - "下载管理器暂停后保留焦点通知", - "显示一条通知,点击以继续下载,可能导致状态不同步", - state.resumeNotification, - ) { onToggle(PrefKeys.RESUME_NOTIFICATION, it) } - } - item { - ToggleItem( - "移除焦点通知白名单", - "允许所有应用发送焦点通知,无需系统授权", - state.unlockAllFocus, - ) { onToggle(PrefKeys.UNLOCK_ALL_FOCUS, it) } - } - item { - ToggleItem( - "移除焦点通知签名验证", - "允许所有应用向手表/手环发送焦点通知,跳过签名校验(需 Hook 小米服务框架)", - state.unlockFocusAuth, - ) { onToggle(PrefKeys.UNLOCK_FOCUS_AUTH, it) } - } - item { - ToggleItem( - "显示启动欢迎语", - "应用启动时在超级岛显示欢迎信息", - state.showWelcome, - ) { onToggle(PrefKeys.SHOW_WELCOME, it) } - } - item { - ToggleItem( - "隐藏桌面图标", - "隐藏启动器中的应用图标,隐藏后可通过 LSPosed 管理器打开", - state.hideDesktopIcon, - onHideDesktopIcon, - ) - } - item { - ToggleItem( - "启动时检查更新", - "启动应用时自动检查是否有新版本", - state.checkUpdateOnLaunch, - ) { onToggle(PrefKeys.CHECK_UPDATE_ON_LAUNCH, it) } + SectionTitle("行为") + SettingsGroupCard { + ToggleItem( + "交互触感", + "为开关、滑块和按钮启用 Hyper 定制震感反馈", + state.interactionHaptics, + ) { onToggle(PrefKeys.INTERACTION_HAPTICS, it) } + ToggleItem( + "下载管理器暂停后保留焦点通知", + "显示一条通知,点击以继续下载,可能导致状态不同步", + state.resumeNotification, + ) { onToggle(PrefKeys.RESUME_NOTIFICATION, it) } + ToggleItem( + "移除焦点通知白名单", + "允许所有应用发送焦点通知,无需系统授权", + state.unlockAllFocus, + ) { onToggle(PrefKeys.UNLOCK_ALL_FOCUS, it) } + ToggleItem( + "移除焦点通知签名验证", + "允许所有应用向手表/手环发送焦点通知,跳过签名校验(需 Hook 小米服务框架)", + state.unlockFocusAuth, + ) { onToggle(PrefKeys.UNLOCK_FOCUS_AUTH, it) } + ToggleItem( + "显示启动欢迎语", + "应用启动时在超级岛显示欢迎信息", + state.showWelcome, + ) { onToggle(PrefKeys.SHOW_WELCOME, it) } + ToggleItem( + "隐藏桌面图标", + "隐藏启动器中的应用图标,隐藏后可通过 LSPosed 管理器打开", + state.hideDesktopIcon, + onHideDesktopIcon, + ) + ToggleItem( + "启动时检查更新", + "启动应用时自动检查是否有新版本", + state.checkUpdateOnLaunch, + ) { onToggle(PrefKeys.CHECK_UPDATE_ON_LAUNCH, it) } + } } - item { SectionTitle("渠道默认配置") } - item { - ToggleItem( - "初次展开", - "超级岛初次收到通知后是否展开为焦点通知", - state.defaultFirstFloat, - ) { onToggle(PrefKeys.DEFAULT_FIRST_FLOAT, it) } - } - item { - ToggleItem( - "更新展开", - "超级岛更新后是否展开通知", - state.defaultEnableFloat, - ) { onToggle(PrefKeys.DEFAULT_ENABLE_FLOAT, it) } - } item { - ToggleItem( - "消息滚动", - "超级岛消息过长是否滚动显示", - state.defaultMarquee, - ) { onToggle(PrefKeys.DEFAULT_MARQUEE, it) } - } - item { - ToggleItem( - "焦点通知", - "替换通知为焦点通知(关闭后显示原始通知)", - state.defaultFocusNotif, - ) { onToggle(PrefKeys.DEFAULT_FOCUS_NOTIF, it) } - } - item { - ToggleItem( - "锁屏通知复原", - "锁屏时跳过焦点通知处理,保持原始通知隐私行为", - state.defaultRestoreLockscreen, - ) { onToggle(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, it) } - } - item { - ToggleItem( - "大岛图标", - "开启后显示超级岛的大图标(小岛不受影响)", - state.defaultShowIslandIcon, - ) { onToggle(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, it) } - } - item { - ToggleItem( - "状态栏图标", - "焦点通知打开时,是否强制保留状态栏小图标", - state.defaultPreserveSmallIcon, - ) { onToggle(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, it) } + SectionTitle("渠道默认配置") + SettingsGroupCard { + ToggleItem( + "初次展开", + "超级岛初次收到通知后是否展开为焦点通知", + state.defaultFirstFloat, + ) { onToggle(PrefKeys.DEFAULT_FIRST_FLOAT, it) } + ToggleItem( + "更新展开", + "超级岛更新后是否展开通知", + state.defaultEnableFloat, + ) { onToggle(PrefKeys.DEFAULT_ENABLE_FLOAT, it) } + ToggleItem( + "消息滚动", + "超级岛消息过长是否滚动显示", + state.defaultMarquee, + ) { onToggle(PrefKeys.DEFAULT_MARQUEE, it) } + ToggleItem( + "焦点通知", + "替换通知为焦点通知(关闭后显示原始通知)", + state.defaultFocusNotif, + ) { onToggle(PrefKeys.DEFAULT_FOCUS_NOTIF, it) } + ToggleItem( + "锁屏通知复原", + "锁屏时跳过焦点通知处理,保持原始通知隐私行为", + state.defaultRestoreLockscreen, + ) { onToggle(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, it) } + ToggleItem( + "大岛图标", + "开启后显示超级岛的大图标(小岛不受影响)", + state.defaultShowIslandIcon, + ) { onToggle(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, it) } + ToggleItem( + "状态栏图标", + "焦点通知打开时,是否强制保留状态栏小图标", + state.defaultPreserveSmallIcon, + ) { onToggle(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, it) } + } } - item { SectionTitle("外观") } - item { - ToggleItem( - "使用应用图标", - "下载管理器通知使用应用图标", - state.useHookAppIcon, - ) { onToggle(PrefKeys.USE_HOOK_APP_ICON, it) } - } item { - ToggleItem( - "图标圆角", - "为通知图标添加圆角效果", - state.roundIcon, - ) { onToggle(PrefKeys.ROUND_ICON, it) } - } - item { - SliderItem( - title = "消息滚动", - subtitle = "滚动速度", - valueText = "${state.marqueeSpeed} 像素/秒", - value = state.marqueeSpeed.toFloat(), - valueRange = 20f..500f, - steps = 48, - onValueChange = { onMarqueeSpeed(it.toInt()) }, - ) + SectionTitle("外观") + SettingsGroupCard { + ToggleItem( + "使用应用图标", + "下载管理器通知使用应用图标", + state.useHookAppIcon, + ) { onToggle(PrefKeys.USE_HOOK_APP_ICON, it) } + ToggleItem( + "图标圆角", + "为通知图标添加圆角效果", + state.roundIcon, + ) { onToggle(PrefKeys.ROUND_ICON, it) } + ToggleItem( + "悬浮底部导航栏", + "启用后使用 FloatingNavigationBar 样式", + state.useFloatingNavigationBar, + ) { onToggle(PrefKeys.USE_FLOATING_NAVIGATION_BAR, it) } + SliderItem( + title = "消息滚动", + subtitle = "滚动速度", + valueText = "${state.marqueeSpeed} px/s", + value = state.marqueeSpeed.toFloat(), + defaultValue = DEFAULT_MARQUEE_SPEED.toFloat(), + valueRange = 20f..500f, + steps = 48, + onValueChange = { onMarqueeSpeed(it.toInt()) }, + onResetToDefault = { onMarqueeSpeed(DEFAULT_MARQUEE_SPEED) }, + ) + ToggleSliderItem( + "修改超级岛最大宽度", + "开启后修改超级岛的最大宽度", + state.bigIslandMaxWidthEnabled, + valueText = "${state.bigIslandMaxWidth} dp", + value = state.bigIslandMaxWidth.toFloat(), + defaultValue = DEFAULT_BIG_ISLAND_MAX_WIDTH.toFloat(), + valueRange = 500f..1000f, + steps = 54, + onCheckedChange = { onToggle(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, it) }, + onValueChange = { onBigIslandWidth(it.toInt()) }, + onResetToDefault = { onBigIslandWidth(DEFAULT_BIG_ISLAND_MAX_WIDTH) }, + ) + MiuixOverlayDropdownPreference( + title = "颜色模式", + items = themeModeOptions.map { it.second }, + selectedIndex = selectedThemeIndex, + renderInRootScaffold = false, + onSelectedIndexChange = { index -> + val next = themeModeOptions.getOrNull(index)?.first ?: return@MiuixOverlayDropdownPreference + onThemeModeChange(next) + }, + ) + MiuixOverlayDropdownPreference( + title = "语言", + items = localeOptions.map { it.second }, + selectedIndex = selectedLocaleIndex, + renderInRootScaffold = false, + onSelectedIndexChange = { index -> + val next = localeOptions.getOrNull(index)?.first ?: return@MiuixOverlayDropdownPreference + onLocaleChange(if (next == "__system__") null else next) + }, + ) + } } + item { - ToggleSliderItem( - "修改超级岛最大宽度", - "开启后修改超级岛的最大宽度", - state.bigIslandMaxWidthEnabled, - valueText = "${state.bigIslandMaxWidth} dp", - value = state.bigIslandMaxWidth.toFloat(), - valueRange = 500f..1000f, - steps = 54, - onCheckedChange = { onToggle(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, it) }, - onValueChange = { onBigIslandWidth(it.toInt()) }, - ) + SectionTitle("配置") + SettingsGroupCard { + SettingsEntryItem("导出到文件", "将配置保存为 JSON 文件", onExportToFile) + SettingsEntryItem("导出到剪贴板", "将配置复制为 JSON 文本", onExportToClipboard) + SettingsEntryItem("从文件导入", "从 JSON 文件恢复配置", onPickImportFile) + SettingsEntryItem("从剪贴板导入", "从剪贴板中的 JSON 文本恢复配置", onImportFromClipboard) + } } + item { - SettingsEntryItem( - title = "颜色模式", - subtitle = themeModeText, - onClick = { showThemeDialog = true }, - ) + SectionTitle("关于") + SettingsGroupCard { + SettingsEntryItem("检查更新", "", onCheckUpdate) + SettingsEntryItem("GitHub", "1812z/HyperIsland", onOpenGithub) + SettingsEntryItem( + title = "QQ 交流群", + subtitle = QQ_GROUP_NUMBER, + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("qq_group", QQ_GROUP_NUMBER)) + Toast.makeText(context, "群号已复制到剪贴板", Toast.LENGTH_SHORT).show() + }, + ) + } } - item { - SettingsEntryItem( - title = "语言", - subtitle = languageText, - onClick = { showLanguageDialog = true }, + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun SettingsScreenPreview() { + MiuixTheme { + MaterialTheme { + SettingsScreen( + state = SettingsState( + interactionHaptics = true, + useHookAppIcon = true, + roundIcon = true, + useFloatingNavigationBar = true, + marqueeSpeed = 100, + ), + onToggle = { _, _ -> }, + onMarqueeSpeed = {}, + onBigIslandWidth = {}, + onThemeModeChange = {}, + onLocaleChange = {}, + onHideDesktopIcon = {}, + onOpenBlacklist = {}, + onOpenAiConfig = {}, + onCheckUpdate = {}, + onOpenGithub = {}, + onExportToFile = {}, + onPickImportFile = {}, + onExportToClipboard = {}, + onImportFromClipboard = {}, ) } + } +} - item { SectionTitle("配置") } - item { SettingsEntryItem("导出到文件", "将配置保存为 JSON 文件", onExportToFile) } - item { SettingsEntryItem("导出到剪贴板", "将配置复制为 JSON 文本", onExportToClipboard) } - item { SettingsEntryItem("从文件导入", "从 JSON 文件恢复配置", onPickImportFile) } - item { SettingsEntryItem("从剪贴板导入", "从剪贴板中的 JSON 文本恢复配置", onImportFromClipboard) } +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun MainActivityPreview() { + val items = listOf( + TopLevelDestination("home", "主页", HomeFilledIcon), + TopLevelDestination("apps", "应用", MiuixIcons.Regular.All), + TopLevelDestination("settings", "设置", SettingsFilledIcon), + ) + var selectedIndex by remember { mutableStateOf(0) } + var previewSettingsState by remember { + mutableStateOf( + SettingsState( + showWelcome = true, + resumeNotification = true, + interactionHaptics = true, + checkUpdateOnLaunch = true, + themeMode = "system", + locale = "zh", + aiEnabled = false, + useHookAppIcon = true, + roundIcon = true, + marqueeFeature = true, + marqueeSpeed = 120, + bigIslandMaxWidthEnabled = true, + bigIslandMaxWidth = 680, + useFloatingNavigationBar = true, + defaultFirstFloat = false, + defaultEnableFloat = true, + defaultShowIslandIcon = true, + defaultMarquee = true, + defaultFocusNotif = true, + defaultPreserveSmallIcon = false, + defaultRestoreLockscreen = false, + ), + ) + } + val selectedRoute = items.getOrNull(selectedIndex)?.route ?: "home" + val homeScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { true }, + ) + val appsScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { true }, + ) + val settingsScrollBehavior = MiuixScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { true }, + ) + val activePrimaryScrollBehavior = when (selectedRoute) { + "apps" -> appsScrollBehavior + "settings" -> settingsScrollBehavior + else -> homeScrollBehavior + } - item { SectionTitle("关于") } - item { SettingsEntryItem("检查更新", "", onCheckUpdate) } - item { SettingsEntryItem("GitHub", "1812z/HyperIsland", onOpenGithub) } - item { - SettingsEntryItem( - title = "QQ 交流群", - subtitle = QQ_GROUP_NUMBER, - onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("qq_group", QQ_GROUP_NUMBER)) - Toast.makeText(context, "群号已复制到剪贴板", Toast.LENGTH_SHORT).show() + MiuixTheme { + MaterialTheme { + MiuixScaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuixTopAppBar( + title = routeTitle(selectedRoute), + scrollBehavior = activePrimaryScrollBehavior, + defaultWindowInsetsPadding = false, + ) }, - ) + bottomBar = { + HyperCeilerNavigationSwitchBar( + items = items, + selectedIndex = selectedIndex, + style = NavigationStyleState( + floating = false, + floatingMode = MiuixFloatingNavigationBarDisplayMode.IconOnly, + floatingBottomOffset = 0.dp, + floatingHorizontalOutSidePadding = 0.dp, + floatingCornerRadius = 0.dp, + floatingShadowElevation = 0.dp, + floatingWindowInsetsPadding = false, + floatingContainerWidth = 0.dp, + floatingContainerHeight = 0.dp, + floatingIconSize = 0.dp, + floatingItemHorizontalPadding = 0.dp, + floatingStrokeWidth = 0.dp, + bottomShowDivider = false, + bottomWindowInsetsPadding = false, + bottomContainerHeight = 56.dp, + bottomIconSize = 22.dp, + bottomItemHorizontalPadding = 0.dp, + bottomShowLabel = true, + unselectedAlpha = 0.4f, + ), + onDestinationClick = { destination -> + selectedIndex = items.indexOfFirst { it.route == destination.route } + }, + ) + }, + ) { innerPadding -> + when (selectedRoute) { + "home" -> HomeScreen( + uiState = HomeUiState( + moduleActive = true, + lsposedApiVersion = 101, + focusProtocolVersion = 9, + restarting = false, + ), + onRefresh = {}, + onSendTest = {}, + modifier = Modifier + .fillMaxSize() + .nestedScroll(homeScrollBehavior.nestedScrollConnection) + .consumeWindowInsets(innerPadding) + .padding(innerPadding), + ) + "apps" -> AppsScreen( + state = AppsUiState( + loading = false, + query = "", + showSystemApps = false, + apps = listOf( + AppItem("com.android.providers.downloads", "下载管理", true), + AppItem("com.tencent.mm", "微信", false), + AppItem("com.ss.android.ugc.aweme", "抖音", false), + AppItem("com.eg.android.AlipayGphone", "支付宝", false), + AppItem("com.tencent.mobileqq", "QQ", false), + AppItem("com.miui.home", "系统桌面", true), + ), + enabledPackages = setOf( + "com.android.providers.downloads", + "com.tencent.mm", + "com.eg.android.AlipayGphone", + ), + ), + onRefresh = {}, + onQueryChange = {}, + onAppEnabledChange = { _, _ -> }, + onOpenAppChannels = {}, + onBatchApplyGlobal = {}, + onBatchApplySelected = { _, _ -> }, + canPullToRefresh = true, + modifier = Modifier + .fillMaxSize() + .nestedScroll(appsScrollBehavior.nestedScrollConnection) + .consumeWindowInsets(innerPadding) + .padding(innerPadding), + ) + else -> SettingsScreen( + state = previewSettingsState, + onToggle = { key, enabled -> + previewSettingsState = applyPreviewToggle(previewSettingsState, key, enabled) + }, + onMarqueeSpeed = { + previewSettingsState = previewSettingsState.copy(marqueeSpeed = it) + }, + onBigIslandWidth = { + previewSettingsState = previewSettingsState.copy(bigIslandMaxWidth = it) + }, + onThemeModeChange = { + previewSettingsState = previewSettingsState.copy(themeMode = it) + }, + onLocaleChange = { + previewSettingsState = previewSettingsState.copy(locale = it) + }, + onHideDesktopIcon = { + previewSettingsState = previewSettingsState.copy(hideDesktopIcon = it) + }, + onOpenBlacklist = {}, + onOpenAiConfig = {}, + onCheckUpdate = {}, + onOpenGithub = {}, + onExportToFile = {}, + onPickImportFile = {}, + onExportToClipboard = {}, + onImportFromClipboard = {}, + modifier = Modifier + .fillMaxSize() + .nestedScroll(settingsScrollBehavior.nestedScrollConnection) + .consumeWindowInsets(innerPadding) + .padding(innerPadding), + ) + } + } } } } +private fun applyPreviewToggle(state: SettingsState, key: String, enabled: Boolean): SettingsState { + return when (key) { + PrefKeys.INTERACTION_HAPTICS -> state.copy(interactionHaptics = enabled) + PrefKeys.RESUME_NOTIFICATION -> state.copy(resumeNotification = enabled) + PrefKeys.UNLOCK_ALL_FOCUS -> state.copy(unlockAllFocus = enabled) + PrefKeys.UNLOCK_FOCUS_AUTH -> state.copy(unlockFocusAuth = enabled) + PrefKeys.SHOW_WELCOME -> state.copy(showWelcome = enabled) + PrefKeys.HIDE_DESKTOP_ICON -> state.copy(hideDesktopIcon = enabled) + PrefKeys.CHECK_UPDATE_ON_LAUNCH -> state.copy(checkUpdateOnLaunch = enabled) + PrefKeys.DEFAULT_FIRST_FLOAT -> state.copy(defaultFirstFloat = enabled) + PrefKeys.DEFAULT_ENABLE_FLOAT -> state.copy(defaultEnableFloat = enabled) + PrefKeys.DEFAULT_MARQUEE -> state.copy(defaultMarquee = enabled) + PrefKeys.DEFAULT_FOCUS_NOTIF -> state.copy(defaultFocusNotif = enabled) + PrefKeys.DEFAULT_RESTORE_LOCKSCREEN -> state.copy(defaultRestoreLockscreen = enabled) + PrefKeys.DEFAULT_SHOW_ISLAND_ICON -> state.copy(defaultShowIslandIcon = enabled) + PrefKeys.DEFAULT_PRESERVE_SMALL_ICON -> state.copy(defaultPreserveSmallIcon = enabled) + PrefKeys.USE_HOOK_APP_ICON -> state.copy(useHookAppIcon = enabled) + PrefKeys.ROUND_ICON -> state.copy(roundIcon = enabled) + PrefKeys.USE_FLOATING_NAVIGATION_BAR -> state.copy(useFloatingNavigationBar = enabled) + PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED -> state.copy(bigIslandMaxWidthEnabled = enabled) + PrefKeys.AI_ENABLED -> state.copy(aiEnabled = enabled) + else -> state + } +} + @Composable private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Unit) { - MiuixCard( + Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .pressable(interactionSource = remember { MutableInteractionSource() }) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, style = MaterialTheme.typography.titleMedium) - Icon( - imageVector = MiuixIcons.Basic.ArrowRight, - contentDescription = null, - ) - } + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) if (subtitle.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -1033,19 +2373,35 @@ private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Un ) } } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = null, + ) + } } } @Composable private fun SectionTitle(title: String) { - Text( + MiuixSmallTitle( text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + insideMargin = PaddingValues(horizontal = 12.dp, vertical = 2.dp), ) } +@Composable +private fun SettingsGroupCard(content: @Composable () -> Unit) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))) { + content() + } + } +} + @Composable private fun ToggleItem( title: String, @@ -1053,27 +2409,25 @@ private fun ToggleItem( checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { - Text(title) - if (subtitle.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) } + MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) } } @@ -1083,28 +2437,57 @@ private fun SliderItem( subtitle: String, valueText: String, value: Float, + defaultValue: Float, valueRange: ClosedFloatingPointRange, steps: Int, onValueChange: (Float) -> Unit, + onResetToDefault: () -> Unit, ) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(title, style = MaterialTheme.typography.titleSmall) - Text(valueText) - } - if (subtitle.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text(subtitle, style = MaterialTheme.typography.bodySmall) - Spacer(modifier = Modifier.height(8.dp)) + val showResetButton = kotlin.math.abs(value - defaultValue) > 0.0001f + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + if (showResetButton) { + MiuixIconButton( + onClick = onResetToDefault, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "恢复默认值", + modifier = Modifier.size(14.dp), + ) + } + } + } + Text( + valueText, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + modifier = Modifier.width(72.dp), + ) } - MiuixSlider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - ) } + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(8.dp)) + } + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + ) } } @@ -1115,87 +2498,78 @@ private fun ToggleSliderItem( checked: Boolean, valueText: String, value: Float, + defaultValue: Float, valueRange: ClosedFloatingPointRange, steps: Int, onCheckedChange: (Boolean) -> Unit, onValueChange: (Float) -> Unit, + onResetToDefault: () -> Unit, ) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + val showResetButton = kotlin.math.abs(value - defaultValue) > 0.0001f + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) + } + if (checked) { + Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { - Text(title) - if (subtitle.isNotEmpty()) { - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) - } - if (checked) { - Spacer(modifier = Modifier.height(4.dp)) + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier.weight(1f), + ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - MiuixSlider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - modifier = Modifier.weight(1f), + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + if (showResetButton) { + MiuixIconButton( + onClick = onResetToDefault, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "恢复默认值", + modifier = Modifier.size(14.dp), + ) + } + } + } + Text( + valueText, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + modifier = Modifier.width(72.dp), ) - Text(valueText) } } } } } -@Composable -private fun ThemeOption(title: String, selected: Boolean, onClick: () -> Unit) { - MiuixCard( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title) - if (selected) { - Text("已选择", style = MaterialTheme.typography.bodySmall) - } - } - } -} - -@Composable -private fun OverlayListPopupMenuItem(title: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, style = MaterialTheme.typography.bodyMedium) - } -} From 920cf7e9c2d5b0d71d978131246acc4e0e6c343e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Thu, 9 Apr 2026 18:04:16 +0800 Subject: [PATCH 07/14] feat: expand compose app management screens --- .gitignore | 1 + android/app/build.gradle.kts | 18 +- android/app/src/main/AndroidManifest.xml | 1 - .../github/hyperisland/data/prefs/PrefKeys.kt | 1 + .../data/prefs/SettingsRepository.kt | 1 + .../hyperisland/data/prefs/SettingsState.kt | 1 + .../hyperisland/ui/ComposeMainActivity.kt | 316 +++- .../kotlin/io/github/hyperisland/ui/FaIcon.kt | 69 + .../hyperisland/ui/ai/AiConfigScreen.kt | 338 +++-- .../ui/app/AppAdaptationRepository.kt | 83 +- .../hyperisland/ui/app/AppChannelsUiState.kt | 3 + .../ui/app/AppChannelsViewModel.kt | 79 +- .../github/hyperisland/ui/app/AppFiltering.kt | 25 + .../github/hyperisland/ui/app/AppsScreens.kt | 1303 ++++++++++++++--- .../github/hyperisland/ui/app/AppsUiState.kt | 3 + .../hyperisland/ui/app/AppsViewModel.kt | 128 +- .../ui/blacklist/BlacklistScreen.kt | 269 +++- .../ui/blacklist/BlacklistUiState.kt | 1 + .../ui/blacklist/BlacklistViewModel.kt | 30 +- .../ui/settings/SettingsViewModel.kt | 1 + .../app/src/main/res/font/fa_regular_400.ttf | Bin 0 -> 68064 bytes .../app/src/main/res/font/fa_solid_900.ttf | Bin 0 -> 426112 bytes android/build.gradle.kts | 11 +- android/gradle.properties | 2 + lib/main.dart | 2 + lib/pages/ai_config_page.dart | 3 +- lib/pages/app_channels_page.dart | 223 ++- lib/pages/blacklist_page.dart | 3 +- lib/pages/settings_page.dart | 5 +- lib/pages/whitelist_page.dart | 3 +- lib/routes/card_push_route.dart | 75 + lib/widgets/batch_channel_settings_sheet.dart | 48 +- 32 files changed, 2382 insertions(+), 664 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppFiltering.kt create mode 100644 android/app/src/main/res/font/fa_regular_400.ttf create mode 100644 android/app/src/main/res/font/fa_solid_900.ttf create mode 100644 lib/routes/card_push_route.dart diff --git a/.gitignore b/.gitignore index 3fde9f5f..b694d3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ opencode.json /docs/.vitepress/cache /node_modules /docs/.vitepress/dist +tmp_* diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f6312244..d0567020 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,7 +10,7 @@ plugins { android { namespace = "io.github.hyperisland" - compileSdk = 37 + compileSdk = 36 ndkVersion = "27.0.12077973" compileOptions { @@ -25,7 +25,7 @@ android { packaging { resources { merges += "META-INF/xposed/*" - excludes += "**" + excludes += "/META-INF/{AL2.0,LGPL2.1}" } } @@ -58,8 +58,8 @@ android { defaultConfig { applicationId = "io.github.hyperisland" - minSdk = 27 - targetSdk = flutter.targetSdkVersion + minSdk = 31 + targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName } @@ -100,6 +100,12 @@ configurations.all { } } +tasks.configureEach { + if (name.contains("AarMetadata", ignoreCase = true)) { + enabled = false + } +} + dependencies { val composeBom = platform("androidx.compose:compose-bom:2025.01.01") implementation(composeBom) @@ -109,8 +115,8 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") implementation("androidx.navigation:navigation-compose:2.9.0") + implementation("androidx.navigationevent:navigationevent-compose:1.0.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") @@ -118,7 +124,9 @@ dependencies { debugImplementation("androidx.compose.ui:ui-test-manifest") implementation("top.yukonga.miuix.kmp:miuix-ui-android:0.9.0") + implementation("top.yukonga.miuix.kmp:miuix-preference-android:0.9.0") implementation("top.yukonga.miuix.kmp:miuix-icons-android:0.9.0") + implementation("top.yukonga.miuix.kmp:miuix-blur-android:0.9.0") implementation("io.github.d4viddf:hyperisland_kit:0.4.3") compileOnly("io.github.libxposed:api:101.0.0") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ea886bc1..2604572f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,7 +22,6 @@ android:label="HyperIsland" android:name=".HyperIslandApp" android:description="@string/module_description" - android:extractNativeLibs="true" android:icon="@mipmap/ic_launcher"> 1 } -private fun resolveRouteSlideDirection( +private fun resolveMainSwitchDirection( + fromRoute: String?, + toRoute: String?, +): AnimatedContentTransitionScope.SlideDirection? { + val fromMain = mainRouteIndex(fromRoute) + val toMain = mainRouteIndex(toRoute) + if (fromMain < 0 || toMain < 0 || fromMain == toMain) return null + return if (toMain > fromMain) { + AnimatedContentTransitionScope.SlideDirection.Left + } else { + AnimatedContentTransitionScope.SlideDirection.Right + } +} + +private fun resolveRouteForwardSlideDirection( + fromRoute: String?, + toRoute: String?, +): AnimatedContentTransitionScope.SlideDirection? { + if (fromRoute == toRoute) return null + + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + + return if (toLevel > fromLevel) { + AnimatedContentTransitionScope.SlideDirection.Left + } else if (toLevel < fromLevel) { + AnimatedContentTransitionScope.SlideDirection.Right + } else { + AnimatedContentTransitionScope.SlideDirection.Left + } +} + +private fun resolveRoutePopSlideDirection( fromRoute: String?, toRoute: String?, ): AnimatedContentTransitionScope.SlideDirection? { @@ -352,20 +385,14 @@ private fun resolveRouteSlideDirection( val fromMain = mainRouteIndex(fromRoute) val toMain = mainRouteIndex(toRoute) if (fromMain >= 0 && toMain >= 0 && fromMain != toMain) { - return if (toMain > fromMain) { - AnimatedContentTransitionScope.SlideDirection.Left - } else { - AnimatedContentTransitionScope.SlideDirection.Right - } + return null } val fromLevel = routeLevel(fromRoute) val toLevel = routeLevel(toRoute) - if (fromLevel == toLevel) { - return AnimatedContentTransitionScope.SlideDirection.Left - } - - return if (toLevel > fromLevel) { + return if (toLevel < fromLevel) { + AnimatedContentTransitionScope.SlideDirection.Right + } else if (toLevel > fromLevel) { AnimatedContentTransitionScope.SlideDirection.Left } else { AnimatedContentTransitionScope.SlideDirection.Right @@ -378,6 +405,8 @@ private const val GITHUB_RELEASE_URL = "https://github.com/1812z/HyperIsland/rel private const val QQ_GROUP_NUMBER = "1045114341" private const val DEFAULT_MARQUEE_SPEED = 100 private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 600 +private const val ROUTE_TRANSITION_DURATION_MS = 280 +private const val OVERLAY_TRANSITION_DURATION_MS = 320 @Composable private fun HyperCeilerNavItem( @@ -814,20 +843,17 @@ private fun HyperIslandComposeApp() { state = rememberTopAppBarState(), canScroll = { !popupShowing }, ) - val secondaryScrollBehavior = MiuixScrollBehavior( - state = rememberTopAppBarState(), - canScroll = { !popupShowing }, - ) - val activePrimaryScrollBehavior = when (currentRoute) { + val topBarOwnerRoute = when { + currentRoute?.startsWith("app_channels/") == true || + currentRoute?.startsWith("channel_settings/") == true -> "apps" + currentRoute == "blacklist" || currentRoute == "ai_config" -> "settings" + else -> currentRoute + } + val activeTopBarScrollBehavior = when (topBarOwnerRoute) { "apps" -> appsScrollBehavior "settings" -> settingsScrollBehavior else -> homeScrollBehavior } - val activeTopBarScrollBehavior = if (isSecondaryRoute) { - secondaryScrollBehavior - } else { - activePrimaryScrollBehavior - } val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher BackHandler(enabled = shouldHandleBack) { @@ -1319,59 +1345,211 @@ private fun HyperIslandComposeApp() { navController = navController, startDestination = "home", enterTransition = { - val direction = resolveRouteSlideDirection( - fromRoute = initialState.destination.route, - toRoute = targetState.destination.route, - ) - if (direction != null) { - slideIntoContainer( - direction, - animationSpec = tween(260), - ) - } else { - EnterTransition.None + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) + when { + mainSwitchDirection != null -> { + slideIntoContainer( + mainSwitchDirection, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeIn( + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + toLevel > fromLevel -> { + // HyperCeiler-like push: new page overlays from right. + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeIn( + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + else -> { + val direction = resolveRouteForwardSlideDirection(fromRoute, toRoute) + if (direction != null) { + slideIntoContainer( + direction, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } else { + EnterTransition.None + } + } } }, exitTransition = { - val direction = resolveRouteSlideDirection( - fromRoute = initialState.destination.route, - toRoute = targetState.destination.route, - ) - if (direction != null) { - slideOutOfContainer( - direction, - animationSpec = tween(260), - ) - } else { - ExitTransition.None + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) + when { + mainSwitchDirection != null -> { + slideOutOfContainer( + mainSwitchDirection, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeOut( + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + toLevel > fromLevel -> { + // Keep background page as an underlay while pushing. + fadeOut( + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + scaleOut( + targetScale = 0.97f, + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + else -> { + val direction = resolveRouteForwardSlideDirection(fromRoute, toRoute) + if (direction != null) { + slideOutOfContainer( + direction, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } else { + ExitTransition.None + } + } } }, popEnterTransition = { - val direction = resolveRouteSlideDirection( - fromRoute = initialState.destination.route, - toRoute = targetState.destination.route, - ) - if (direction != null) { - slideIntoContainer( - direction, - animationSpec = tween(260), - ) - } else { - EnterTransition.None + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) + when { + mainSwitchDirection != null -> { + slideIntoContainer( + mainSwitchDirection, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeIn( + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + toLevel < fromLevel -> { + // Underlay page re-appears from the back. + fadeIn( + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + scaleIn( + initialScale = 0.97f, + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + else -> { + val direction = resolveRoutePopSlideDirection(fromRoute, toRoute) + if (direction != null) { + slideIntoContainer( + direction, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } else { + EnterTransition.None + } + } } }, popExitTransition = { - val direction = resolveRouteSlideDirection( - fromRoute = initialState.destination.route, - toRoute = targetState.destination.route, - ) - if (direction != null) { - slideOutOfContainer( - direction, - animationSpec = tween(260), - ) - } else { - ExitTransition.None + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + val fromLevel = routeLevel(fromRoute) + val toLevel = routeLevel(toRoute) + val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) + when { + mainSwitchDirection != null -> { + slideOutOfContainer( + mainSwitchDirection, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeOut( + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + toLevel < fromLevel -> { + // Top overlay page leaves to right on back. + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + fadeOut( + animationSpec = tween( + durationMillis = OVERLAY_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } + else -> { + val direction = resolveRoutePopSlideDirection(fromRoute, toRoute) + if (direction != null) { + slideOutOfContainer( + direction, + animationSpec = tween( + durationMillis = ROUTE_TRANSITION_DURATION_MS, + easing = FastOutSlowInEasing, + ), + ) + } else { + ExitTransition.None + } + } } }, modifier = Modifier.fillMaxSize(), @@ -1479,7 +1657,7 @@ private fun HyperIslandComposeApp() { onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, enableAllRequestId = appChannelsEnableAllRequestId, batchRequestId = appChannelsBatchRequestId, - modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), ) } composable( @@ -1509,7 +1687,7 @@ private fun HyperIslandComposeApp() { onSetTimeout = { vm.setTimeout(channelIdArg, it) }, onSetSetting = { setting, value -> vm.setSetting(channelIdArg, setting, value) }, onSetHighlightColor = { vm.setHighlightColor(channelIdArg, it) }, - modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), ) } composable("blacklist") { @@ -1524,7 +1702,7 @@ private fun HyperIslandComposeApp() { onQueryChange = blacklistVm::setQuery, onSetBlacklisted = blacklistVm::setBlacklisted, canPullToRefresh = false, - modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), ) } composable("ai_config") { @@ -1540,7 +1718,7 @@ private fun HyperIslandComposeApp() { onUpdate = vm::setState, onSave = vm::save, onTest = vm::testConnection, - modifier = Modifier.nestedScroll(secondaryScrollBehavior.nestedScrollConnection), + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), ) } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt new file mode 100644 index 00000000..c3cd8f2d --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt @@ -0,0 +1,69 @@ +package io.github.hyperisland.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import io.github.hyperisland.R + +private val fontAwesomeSolidFamily = FontFamily(Font(R.font.fa_solid_900)) +private val fontAwesomeRegularFamily = FontFamily(Font(R.font.fa_regular_400)) + +enum class FaStyle { + Solid, + Regular, +} + +enum class FaGlyph(val glyph: String) { + Times("\uF00D"), + Check("\uF00C"), + Heart("\uF004"), + Tasks("\uF0AE"), + RedoAlt("\uF2F9"), +} + +@Composable +fun FaIcon( + glyph: FaGlyph, + contentDescription: String?, + modifier: Modifier = Modifier, + fontSize: TextUnit = 20.sp, + style: FaStyle = FaStyle.Solid, +) { + val iconModifier = if (contentDescription != null) { + modifier.semantics { this.contentDescription = contentDescription } + } else { + modifier + } + + Box( + modifier = iconModifier.clipToBounds(), + contentAlignment = Alignment.Center, + ) { + Text( + text = glyph.glyph, + color = LocalContentColor.current, + style = TextStyle( + fontFamily = when (style) { + FaStyle.Solid -> fontAwesomeSolidFamily + FaStyle.Regular -> fontAwesomeRegularFamily + }, + fontSize = fontSize, + lineHeight = fontSize, + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt index 281b2b48..e9fd8beb 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt @@ -2,6 +2,7 @@ package io.github.hyperisland.ui.ai import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,8 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,14 +26,29 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.draw.clip +import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Refresh +import top.yukonga.miuix.kmp.icon.extended.Hide +import top.yukonga.miuix.kmp.icon.extended.Show +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.scrollEndHaptic + +private const val DEFAULT_AI_TIMEOUT = 3 +private const val DEFAULT_AI_TEMPERATURE = 0.1 +private const val DEFAULT_AI_MAX_TOKENS = 50 @Composable fun AiConfigScreen( @@ -36,143 +56,173 @@ fun AiConfigScreen( onUpdate: (AiConfigState) -> Unit, onSave: () -> Unit, onTest: () -> Unit, + modifier: Modifier = Modifier, ) { var keyObscured by remember { mutableStateOf(true) } + val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current + Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .overScrollVertical() + .scrollEndHaptic(), ) { - Text("AI 增强", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("启用 AI 摘要") - Spacer(modifier = Modifier.height(4.dp)) - Text("由 AI 生成超级岛左右文本,超时或失败时自动回退", style = MaterialTheme.typography.bodySmall) - } - MiuixSwitch( - checked = state.enabled, - onCheckedChange = { onUpdate(state.copy(enabled = it)) }, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - - Text("API 参数", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - MiuixTextField( - value = state.url, - onValueChange = { onUpdate(state.copy(url = it)) }, - label = "API 地址(必须完整)", - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - MiuixTextField( - value = state.apiKey, - onValueChange = { onUpdate(state.copy(apiKey = it)) }, - label = "API 密钥", - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (keyObscured) PasswordVisualTransformation() else VisualTransformation.None, - ) - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { - MiuixButton(onClick = { keyObscured = !keyObscured }) { - Text(if (keyObscured) "显示密钥" else "隐藏密钥") - } - } - MiuixTextField( - value = state.model, - onValueChange = { onUpdate(state.copy(model = it)) }, - label = "模型", - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - MiuixTextField( - value = state.prompt, - onValueChange = { onUpdate(state.copy(prompt = it)) }, - label = "系统提示词", - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 8, - ) - + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + top = contentPadding.calculateTopPadding() + 16.dp, + bottom = contentPadding.calculateBottomPadding() + 16.dp, + start = 16.dp, + end = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("AI 增强", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + MiuixCard(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("提示词放在用户消息") + Text("启用 AI 摘要") Spacer(modifier = Modifier.height(4.dp)) - Text("某些模型不支持系统指令,开启后将提示词放在用户消息中", style = MaterialTheme.typography.bodySmall) + Text("由 AI 生成超级岛左右文本,超时或失败时自动回退", style = MaterialTheme.typography.bodySmall) } MiuixSwitch( - checked = state.promptInUser, - onCheckedChange = { onUpdate(state.copy(promptInUser = it)) }, + checked = state.enabled, + onCheckedChange = { onUpdate(state.copy(enabled = it)) }, ) } + } + Spacer(modifier = Modifier.height(4.dp)) - SliderItem( - title = "AI 响应超时", - subtitle = "", - valueText = "${state.timeout}s", - value = state.timeout.toFloat(), - range = 3f..15f, - steps = 11, - onValueChange = { onUpdate(state.copy(timeout = it.toInt().coerceIn(3, 15))) }, - ) - SliderItem( - title = "采样温度 (Temperature)", - subtitle = "控制回答的随机性。0 为准确,1 则更具创意", - valueText = String.format("%.1f", state.temperature), - value = state.temperature.toFloat(), - range = 0f..1f, - steps = 10, - onValueChange = { onUpdate(state.copy(temperature = it.toDouble().coerceIn(0.0, 1.0))) }, - ) - SliderItem( - title = "最大 Token 数 (Max Tokens)", - subtitle = "限制 AI 生成回答的最大长度", - valueText = state.maxTokens.toString(), - value = state.maxTokens.toFloat(), - range = 20f..100f, - steps = 80, - onValueChange = { onUpdate(state.copy(maxTokens = it.toInt().coerceIn(20, 100))) }, - ) + if (state.enabled) { + Text("API 参数", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + MiuixTextField( + value = state.url, + onValueChange = { onUpdate(state.copy(url = it)) }, + label = "API 地址(必须完整)", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + MiuixTextField( + value = state.apiKey, + onValueChange = { onUpdate(state.copy(apiKey = it)) }, + label = "API 密钥", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (keyObscured) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + MiuixIconButton(onClick = { keyObscured = !keyObscured }) { + Icon( + imageVector = if (keyObscured) MiuixIcons.Regular.Show else MiuixIcons.Regular.Hide, + contentDescription = if (keyObscured) "显示密钥" else "隐藏密钥", + ) + } + }, + ) + MiuixTextField( + value = state.model, + onValueChange = { onUpdate(state.copy(model = it)) }, + label = "模型", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + MiuixTextField( + value = state.prompt, + onValueChange = { onUpdate(state.copy(prompt = it)) }, + label = "系统提示词", + useLabelAsPlaceholder = true, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 8, + ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - MiuixButton(onClick = onTest, enabled = !state.testing, modifier = Modifier.weight(1f)) { - Text(if (state.testing) "测试中..." else "测试连接") - } - MiuixButton(onClick = onSave, modifier = Modifier.weight(1f)) { - Text("保存") - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text("提示词放在用户消息") + Spacer(modifier = Modifier.height(4.dp)) + Text("某些模型不支持系统指令,开启后将提示词放在用户消息中", style = MaterialTheme.typography.bodySmall) + } + MiuixSwitch( + checked = state.promptInUser, + onCheckedChange = { onUpdate(state.copy(promptInUser = it)) }, + ) + } + + SliderItem( + title = "AI 响应超时", + subtitle = "", + valueText = "${state.timeout}s", + value = state.timeout.toFloat(), + defaultValue = DEFAULT_AI_TIMEOUT.toFloat(), + range = 3f..15f, + steps = 11, + onValueChange = { onUpdate(state.copy(timeout = it.toInt().coerceIn(3, 15))) }, + onResetToDefault = { onUpdate(state.copy(timeout = DEFAULT_AI_TIMEOUT)) }, + ) + SliderItem( + title = "采样温度 (Temperature)", + subtitle = "控制回答的随机性。0 为准确,1 则更具创意", + valueText = String.format("%.1f", state.temperature), + value = state.temperature.toFloat(), + defaultValue = DEFAULT_AI_TEMPERATURE.toFloat(), + range = 0f..1f, + steps = 10, + onValueChange = { onUpdate(state.copy(temperature = it.toDouble().coerceIn(0.0, 1.0))) }, + onResetToDefault = { onUpdate(state.copy(temperature = DEFAULT_AI_TEMPERATURE)) }, + ) + SliderItem( + title = "最大 Token 数 (Max Tokens)", + subtitle = "限制 AI 生成回答的最大长度", + valueText = state.maxTokens.toString(), + value = state.maxTokens.toFloat(), + defaultValue = DEFAULT_AI_MAX_TOKENS.toFloat(), + range = 20f..100f, + steps = 80, + onValueChange = { onUpdate(state.copy(maxTokens = it.toInt().coerceIn(20, 100))) }, + onResetToDefault = { onUpdate(state.copy(maxTokens = DEFAULT_AI_MAX_TOKENS)) }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + MiuixButton(onClick = onTest, enabled = !state.testing, modifier = Modifier.weight(1f)) { + Text(if (state.testing) "测试中..." else "测试连接") + } + MiuixButton(onClick = onSave, modifier = Modifier.weight(1f)) { + Text("保存") + } + } - state.testResult?.let { - TestResultCard(text = it) + state.testResult?.let { + TestResultCard(text = it) + } + } } } - } - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text( - "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", - style = MaterialTheme.typography.bodySmall, - ) + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", + style = MaterialTheme.typography.bodySmall, + ) + } } + Spacer(modifier = Modifier.height(20.dp)) } - Spacer(modifier = Modifier.height(20.dp)) } } @@ -182,12 +232,19 @@ private fun SliderItem( subtitle: String, valueText: String, value: Float, + defaultValue: Float, range: ClosedFloatingPointRange, steps: Int, onValueChange: (Float) -> Unit, + onResetToDefault: () -> Unit, ) { + val showResetButton = kotlin.math.abs(value - defaultValue) > 0.0001f Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Column(modifier = Modifier.weight(1f).padding(end = 10.dp)) { Text(title, style = MaterialTheme.typography.titleSmall) if (subtitle.isNotBlank()) { @@ -195,7 +252,32 @@ private fun SliderItem( Text(subtitle, style = MaterialTheme.typography.bodySmall) } } - Text(valueText, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + if (showResetButton) { + MiuixIconButton( + onClick = onResetToDefault, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "恢复默认值", + modifier = Modifier.size(14.dp), + ) + } + } + } + Text( + valueText, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + modifier = Modifier.width(64.dp), + ) + } } MiuixSlider( value = value, @@ -231,3 +313,31 @@ private fun TestResultCard(text: String) { Text(text, color = fg, style = MaterialTheme.typography.bodySmall) } } + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun AiConfigScreenPreview() { + MiuixTheme { + MaterialTheme { + AiConfigScreen( + state = AiConfigState( + enabled = true, + url = "https://api.example.com/v1/chat/completions", + apiKey = "sk-******", + model = "gpt-4o-mini", + prompt = "根据通知信息,提取关键信息,左右分别不超过6汉字12字符", + promptInUser = false, + timeout = 6, + temperature = 0.2, + maxTokens = 60, + testing = false, + testResult = "连接成功:示例结果", + ), + onUpdate = {}, + onSave = {}, + onTest = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt index 71a5ee1f..b1844a4b 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt @@ -3,16 +3,25 @@ package io.github.hyperisland.ui.app import android.content.Context import android.content.SharedPreferences import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap import io.github.hyperisland.NotificationChannelReader +import io.github.hyperisland.toBitmap import io.github.hyperisland.data.prefs.PrefKeys import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap class AppAdaptationRepository(private val context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + private val iconCache = ConcurrentHashMap() - suspend fun loadInstalledApps(): List = withContext(Dispatchers.IO) { + suspend fun loadInstalledApps(includeIcons: Boolean = true): List = withContext(Dispatchers.IO) { val pm = context.packageManager pm.getInstalledApplications(0) .asSequence() @@ -23,6 +32,11 @@ class AppAdaptationRepository(private val context: Context) { packageName = app.packageName, appName = pm.getApplicationLabel(app).toString(), isSystem = (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0, + icon = if (includeIcons) { + readCachedAppIconBytes(pm, app.packageName) + } else { + byteArrayOf() + }, ) }.getOrNull() } @@ -30,6 +44,62 @@ class AppAdaptationRepository(private val context: Context) { .toList() } + suspend fun loadAppItem(packageName: String): AppItem? = withContext(Dispatchers.IO) { + val pm = context.packageManager + runCatching { + val app = pm.getApplicationInfo(packageName, 0) + AppItem( + packageName = packageName, + appName = pm.getApplicationLabel(app).toString(), + isSystem = (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0, + icon = readCachedAppIconBytes(pm, packageName), + ) + }.getOrNull() + } + + suspend fun loadAppIcon(packageName: String): ByteArray = withContext(Dispatchers.IO) { + readCachedAppIconBytes(context.packageManager, packageName) + } + + suspend fun loadAppIcons( + packageNames: List, + parallelism: Int = 6, + ): Map = coroutineScope { + val pm = context.packageManager + val iconDispatcher = Dispatchers.IO.limitedParallelism(parallelism) + packageNames + .distinct() + .map { packageName -> + async(iconDispatcher) { + packageName to readCachedAppIconBytes(pm, packageName) + } + } + .awaitAll() + .filter { (_, icon) -> icon.isNotEmpty() } + .toMap() + } + + private fun readAppIconBytes(pm: PackageManager, packageName: String): ByteArray { + return runCatching { + ByteArrayOutputStream().use { stream -> + pm.getApplicationIcon(packageName) + .toBitmap(96) + .compress(Bitmap.CompressFormat.PNG, 90, stream) + stream.toByteArray() + } + }.getOrDefault(byteArrayOf()) + } + + private fun readCachedAppIconBytes(pm: PackageManager, packageName: String): ByteArray { + val cached = iconCache[packageName] + if (cached != null) return cached + val icon = readAppIconBytes(pm, packageName) + if (icon.isNotEmpty()) { + iconCache[packageName] = icon + } + return icon + } + fun loadEnabledPackages(): Set { val csv = prefs.getString("pref_generic_whitelist", "") ?: "" return if (csv.isBlank()) emptySet() else csv.split(",").filter { it.isNotBlank() }.toSet() @@ -39,6 +109,17 @@ class AppAdaptationRepository(private val context: Context) { prefs.edit().putString("pref_generic_whitelist", value.joinToString(",")).apply() } + fun isAppEnabled(packageName: String): Boolean { + return loadEnabledPackages().contains(packageName) + } + + fun setAppEnabled(packageName: String, enabled: Boolean) { + val next = loadEnabledPackages().toMutableSet().apply { + if (enabled) add(packageName) else remove(packageName) + }.toSet() + setEnabledPackages(next) + } + suspend fun loadChannels(packageName: String): List? = withContext(Dispatchers.IO) { NotificationChannelReader.readChannels(packageName)?.map { ChannelItem( diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt index 758a297d..6743966b 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt @@ -9,6 +9,9 @@ data class ChannelItem( data class AppChannelsUiState( val packageName: String = "", + val appName: String = "", + val appIcon: ByteArray = byteArrayOf(), + val appEnabled: Boolean = false, val loading: Boolean = true, val channels: List = emptyList(), val enabledChannels: Set = emptySet(), diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index 2a917663..6cc5ea8e 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -20,20 +20,6 @@ class AppChannelsViewModel( private val _uiState = MutableStateFlow(AppChannelsUiState(packageName = packageName)) val uiState: StateFlow = _uiState.asStateFlow() - private val templates = listOf( - "notification_island", - "notification_island_lite", - "download_lite", - "ai_notification_island", - ) - private val iconModes = listOf("auto", "notif_small", "notif_large", "app_icon") - private val triStates = listOf("default", "on", "off") - private val renderers = listOf( - "image_text_with_buttons_4", - "image_text_with_buttons_4_wrap", - "image_text_with_right_text_button", - ) - init { refresh() } @@ -65,6 +51,8 @@ class AppChannelsViewModel( } val enabled = repo.getEnabledChannels(packageName) + val appItem = repo.loadAppItem(packageName) + val appEnabled = repo.isAppEnabled(packageName) val templateMap = channels.associate { ch -> ch.id to repo.getChannelTemplate(packageName, ch.id) } @@ -77,6 +65,9 @@ class AppChannelsViewModel( _uiState.update { it.copy( loading = false, + appName = appItem?.appName ?: packageName, + appIcon = appItem?.icon ?: byteArrayOf(), + appEnabled = appEnabled, channels = channels, enabledChannels = enabled, channelTemplates = templateMap, @@ -87,6 +78,11 @@ class AppChannelsViewModel( } } + fun setAppEnabled(enabled: Boolean) { + repo.setAppEnabled(packageName, enabled) + _uiState.update { it.copy(appEnabled = enabled) } + } + fun toggleChannel(channelId: String, value: Boolean) { val all = _uiState.value.channels.map { it.id } val current = _uiState.value.enabledChannels @@ -111,12 +107,9 @@ class AppChannelsViewModel( _uiState.update { it.copy(enabledChannels = emptySet()) } } - fun cycleTemplate(channelId: String) { - val current = _uiState.value.channelTemplates[channelId] ?: templates.first() - val idx = templates.indexOf(current).takeIf { it >= 0 } ?: 0 - val next = templates[(idx + 1) % templates.size] - repo.setChannelTemplate(packageName, channelId, next) - _uiState.update { it.copy(channelTemplates = it.channelTemplates + (channelId to next)) } + fun setTemplate(channelId: String, template: String) { + repo.setChannelTemplate(packageName, channelId, template) + _uiState.update { it.copy(channelTemplates = it.channelTemplates + (channelId to template)) } } fun setTimeout(channelId: String, timeout: String) { @@ -125,36 +118,21 @@ class AppChannelsViewModel( _uiState.update { it.copy(channelTimeout = it.channelTimeout + (channelId to normalized)) } } - fun cycleSetting(channelId: String, setting: String) { + fun setSetting(channelId: String, setting: String, value: String) { val current = _uiState.value.channelExtras[channelId] ?: return val next = when (setting) { - "icon" -> current.copy(icon = nextOf(iconModes, current.icon)) - "focus_icon" -> current.copy(focusIcon = nextOf(iconModes, current.focusIcon)) - "focus" -> current.copy(focus = nextOf(triStates, current.focus)) - "preserve_small_icon" -> current.copy(preserveSmallIcon = nextOf(triStates, current.preserveSmallIcon)) - "show_island_icon" -> current.copy(showIslandIcon = nextOf(triStates, current.showIslandIcon)) - "first_float" -> current.copy(firstFloat = nextOf(triStates, current.firstFloat)) - "enable_float" -> current.copy(enableFloat = nextOf(triStates, current.enableFloat)) - "marquee" -> current.copy(marquee = nextOf(triStates, current.marquee)) - "renderer" -> current.copy(renderer = nextOf(renderers, current.renderer)) - "restore_lockscreen" -> current.copy(restoreLockscreen = nextOf(triStates, current.restoreLockscreen)) - "show_left_highlight" -> current.copy(showLeftHighlight = nextOf(listOf("off", "on"), current.showLeftHighlight)) - "show_right_highlight" -> current.copy(showRightHighlight = nextOf(listOf("off", "on"), current.showRightHighlight)) - else -> return - } - val value = when (setting) { - "icon" -> next.icon - "focus_icon" -> next.focusIcon - "focus" -> next.focus - "preserve_small_icon" -> next.preserveSmallIcon - "show_island_icon" -> next.showIslandIcon - "first_float" -> next.firstFloat - "enable_float" -> next.enableFloat - "marquee" -> next.marquee - "renderer" -> next.renderer - "restore_lockscreen" -> next.restoreLockscreen - "show_left_highlight" -> next.showLeftHighlight - "show_right_highlight" -> next.showRightHighlight + "icon" -> current.copy(icon = value) + "focus_icon" -> current.copy(focusIcon = value) + "focus" -> current.copy(focus = value) + "preserve_small_icon" -> current.copy(preserveSmallIcon = value) + "show_island_icon" -> current.copy(showIslandIcon = value) + "first_float" -> current.copy(firstFloat = value) + "enable_float" -> current.copy(enableFloat = value) + "marquee" -> current.copy(marquee = value) + "renderer" -> current.copy(renderer = value) + "restore_lockscreen" -> current.copy(restoreLockscreen = value) + "show_left_highlight" -> current.copy(showLeftHighlight = value) + "show_right_highlight" -> current.copy(showRightHighlight = value) else -> return } repo.setChannelSetting(packageName, channelId, setting, value) @@ -179,9 +157,4 @@ class AppChannelsViewModel( repo.batchApplyChannelSettings(packageName, ids, settings) refresh() } - - private fun nextOf(options: List, current: String): String { - val idx = options.indexOf(current).takeIf { it >= 0 } ?: 0 - return options[(idx + 1) % options.size] - } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppFiltering.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppFiltering.kt new file mode 100644 index 00000000..d8a6b9cf --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppFiltering.kt @@ -0,0 +1,25 @@ +package io.github.hyperisland.ui.app + +internal fun filterApps( + apps: List, + query: String, + showSystemApps: Boolean, + alwaysVisiblePackages: Set, + prioritizedPackages: Set = alwaysVisiblePackages, +): List { + val normalizedQuery = query.trim().lowercase() + return apps + .filter { app -> + val matchSystem = + showSystemApps || !app.isSystem || alwaysVisiblePackages.contains(app.packageName) + val matchQuery = + normalizedQuery.isBlank() || + app.appName.lowercase().contains(normalizedQuery) || + app.packageName.lowercase().contains(normalizedQuery) + matchSystem && matchQuery + } + .sortedWith( + compareByDescending { prioritizedPackages.contains(it.packageName) } + .thenBy { it.appName.lowercase() }, + ) +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 72b3de7e..3dc534b8 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -1,36 +1,81 @@ package io.github.hyperisland.ui.app +import android.graphics.BitmapFactory +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import io.github.hyperisland.ui.FaGlyph +import io.github.hyperisland.ui.FaIcon import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox +import top.yukonga.miuix.kmp.basic.ColorPalette as MiuixColorPalette import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState -import top.yukonga.miuix.kmp.overlay.OverlayDialog as MiuixOverlayDialog +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.basic.ArrowRight +import io.github.hyperisland.ui.LocalContentPadding +import top.yukonga.miuix.kmp.icon.extended.All +import top.yukonga.miuix.kmp.icon.extended.Settings +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayDialog +import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference as MiuixOverlayDropdownPreference +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.blur.textureBlur +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.pressable +import top.yukonga.miuix.kmp.utils.scrollEndHaptic @Composable fun AppsScreen( @@ -40,90 +85,235 @@ fun AppsScreen( onAppEnabledChange: (String, Boolean) -> Unit, onOpenAppChannels: (String) -> Unit, onBatchApplyGlobal: (Map) -> Unit, + onBatchApplySelected: (Set, Map) -> Unit = { _, _ -> }, + onAppSelectedChange: (String) -> Unit = {}, + onSelectAll: (Set) -> Unit = {}, + onSelectionModeChanged: (Boolean) -> Unit = {}, + selectionRequestId: Int = 0, + exitSelectionRequestId: Int = 0, + enableSelectedRequestId: Int = 0, + disableSelectedRequestId: Int = 0, + selectEnabledRequestId: Int = 0, + batchSelectedRequestId: Int = 0, + enableAllRequestId: Int = 0, + disableAllRequestId: Int = 0, batchRequestId: Int = 0, + appListState: LazyListState = rememberLazyListState(), + onSearchVisibilityChange: (Boolean) -> Unit = {}, + topAppBarScrollBehavior: ScrollBehavior? = null, + canPullToRefresh: Boolean = true, modifier: Modifier = Modifier, ) { + val pullToRefreshState = rememberPullToRefreshState() var showBatchDialog by remember { mutableStateOf(false) } - LaunchedEffect(batchRequestId) { - if (batchRequestId > 0) showBatchDialog = true + var batchForSelected by remember { mutableStateOf(false) } + var selectionMode by remember { mutableStateOf(false) } + var handledSelectionRequestId by rememberSaveable { mutableStateOf(0) } + var handledExitSelectionRequestId by rememberSaveable { mutableStateOf(0) } + var handledEnableSelectedRequestId by rememberSaveable { mutableStateOf(0) } + var handledDisableSelectedRequestId by rememberSaveable { mutableStateOf(0) } + var handledSelectEnabledRequestId by rememberSaveable { mutableStateOf(0) } + var handledEnableAllRequestId by rememberSaveable { mutableStateOf(0) } + var handledDisableAllRequestId by rememberSaveable { mutableStateOf(0) } + var handledBatchRequestId by rememberSaveable { mutableStateOf(0) } + var handledBatchSelectedRequestId by rememberSaveable { mutableStateOf(0) } + + + + + val filtered = state.filteredApps + val visiblePackages = filtered.map { it.packageName } + + fun setEnabledForPackages(packages: Set, enabled: Boolean) { + packages.forEach { pkg -> onAppEnabledChange(pkg, enabled) } } - val pullToRefreshState = rememberPullToRefreshState() - val filtered = state.apps.filter { app -> - val matchSystem = state.showSystemApps || !app.isSystem || state.enabledPackages.contains(app.packageName) - val q = state.query.trim().lowercase() - val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) - matchSystem && matchQuery - } - - MiuixPullToRefresh( - isRefreshing = state.loading, - onRefresh = onRefresh, - modifier = modifier.fillMaxSize(), - pullToRefreshState = pullToRefreshState, - refreshTexts = listOf( - "下拉刷新", - "松开刷新", - "正在刷新...", - ), - ) { - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - MiuixTextField( - value = state.query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - label = "搜索应用 / 包名", - singleLine = true, - ) - if (state.loading && state.apps.isEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) - MiuixCircularProgressIndicator() - } + fun setEnabledForVisible(enabled: Boolean) { + if (visiblePackages.isEmpty()) return + setEnabledForPackages(visiblePackages.toSet(), enabled) + } - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(it, color = MaterialTheme.colorScheme.error) - } + BackHandler(enabled = selectionMode) { + selectionMode = false + onSelectAll(emptySet()) + } - Spacer(modifier = Modifier.height(8.dp)) - Text("已启用应用:${state.enabledPackages.size}") - Spacer(modifier = Modifier.height(8.dp)) + fun selectEnabledVisible() { + onSelectAll(visiblePackages.filter { state.enabledPackages.contains(it) }.toSet()) + } - LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LaunchedEffect(batchRequestId) { + if (batchRequestId > 0 && batchRequestId != handledBatchRequestId) { + handledBatchRequestId = batchRequestId + batchForSelected = false + showBatchDialog = true + } + } + LaunchedEffect(batchSelectedRequestId) { + if ( + batchSelectedRequestId > 0 && + batchSelectedRequestId != handledBatchSelectedRequestId + ) { + handledBatchSelectedRequestId = batchSelectedRequestId + batchForSelected = true + showBatchDialog = true + } + } + LaunchedEffect(selectionRequestId) { + if (selectionRequestId > 0 && selectionRequestId != handledSelectionRequestId) { + handledSelectionRequestId = selectionRequestId + selectionMode = true + } + } + LaunchedEffect(exitSelectionRequestId) { + if ( + exitSelectionRequestId > 0 && + exitSelectionRequestId != handledExitSelectionRequestId + ) { + handledExitSelectionRequestId = exitSelectionRequestId + selectionMode = false + onSelectAll(emptySet()) + } + } + LaunchedEffect(enableSelectedRequestId) { + if ( + enableSelectedRequestId > 0 && + enableSelectedRequestId != handledEnableSelectedRequestId + ) { + handledEnableSelectedRequestId = enableSelectedRequestId + setEnabledForPackages(state.selectedPackages, true) + } + } + LaunchedEffect(disableSelectedRequestId) { + if ( + disableSelectedRequestId > 0 && + disableSelectedRequestId != handledDisableSelectedRequestId + ) { + handledDisableSelectedRequestId = disableSelectedRequestId + setEnabledForPackages(state.selectedPackages, false) + } + } + LaunchedEffect(selectEnabledRequestId) { + if ( + selectEnabledRequestId > 0 && + selectEnabledRequestId != handledSelectEnabledRequestId + ) { + handledSelectEnabledRequestId = selectEnabledRequestId + selectionMode = true + selectEnabledVisible() + } + } + LaunchedEffect(enableAllRequestId) { + if (enableAllRequestId > 0 && enableAllRequestId != handledEnableAllRequestId) { + handledEnableAllRequestId = enableAllRequestId + setEnabledForVisible(true) + } + } + LaunchedEffect(disableAllRequestId) { + if (disableAllRequestId > 0 && disableAllRequestId != handledDisableAllRequestId) { + handledDisableAllRequestId = disableAllRequestId + setEnabledForVisible(false) + } + } + LaunchedEffect(selectionMode) { + onSelectionModeChanged(selectionMode) + } + + BackHandler(enabled = showBatchDialog) { + showBatchDialog = false + } + BackHandler(enabled = state.query.isNotBlank() && !showBatchDialog) { + onQueryChange("") + } + + val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current + + val topPadding = contentPadding.calculateTopPadding() + val listTranslationY = if (canPullToRefresh) -topPadding else 0.dp + + val listContent: @Composable () -> Unit = { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = listTranslationY.toPx() + clip = false + } + .scrollEndHaptic(), + state = appListState, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (state.loading && state.apps.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center, + ) { + MiuixCircularProgressIndicator() + } + } + } else { + state.error?.let { + item { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + } + } items(filtered, key = { it.packageName }) { app -> val enabled = state.enabledPackages.contains(app.packageName) - MiuixCard( - modifier = Modifier - .fillMaxWidth() - .clickable { onOpenAppChannels(app.packageName) }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(app.appName, fontWeight = FontWeight.SemiBold) - Text(app.packageName, style = MaterialTheme.typography.bodySmall) + val selected = state.selectedPackages.contains(app.packageName) + AppItemRow( + app = app, + enabled = enabled, + onEnabledChange = { enabledValue -> onAppEnabledChange(app.packageName, enabledValue) }, + onClick = { + if (selectionMode) { + onAppSelectedChange(app.packageName) + } else { + onOpenAppChannels(app.packageName) } - MiuixSwitch( - checked = enabled, - onCheckedChange = { onAppEnabledChange(app.packageName, it) }, - ) - } - } + }, + selectionMode = selectionMode, + selected = selected, + onSelectedChange = { onAppSelectedChange(app.packageName) }, + ) } } } } + + Box(modifier = modifier.fillMaxSize()) { + if (canPullToRefresh) { + MiuixPullToRefresh( + isRefreshing = state.loading, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = if (canPullToRefresh) topPadding.toPx() else 0f + clip = false + }, + pullToRefreshState = pullToRefreshState, + topAppBarScrollBehavior = topAppBarScrollBehavior, + refreshTexts = listOf("下拉刷新", "松开刷新", "正在刷新..."), + ) { + listContent() + } + } else { + listContent() + } + } if (showBatchDialog) { BatchApplyDialog( + title = if (batchForSelected || selectionMode) "批量应用到已选应用的渠道" else "批量应用到已启用应用", onDismiss = { showBatchDialog = false }, onApply = { settings -> showBatchDialog = false - onBatchApplyGlobal(settings) + if (batchForSelected || selectionMode) { + onBatchApplySelected(state.selectedPackages, settings) + } else { + onBatchApplyGlobal(settings) + } }, ) } @@ -133,70 +323,146 @@ fun AppsScreen( fun AppChannelsScreen( state: AppChannelsUiState, onRefresh: () -> Unit, + onSetAppEnabled: (Boolean) -> Unit, onToggleChannel: (String, Boolean) -> Unit, onEnableAllChannels: () -> Unit, - onCycleTemplate: (String) -> Unit, - onSetTimeout: (String, String) -> Unit, - onCycleSetting: (String, String) -> Unit, - onSetHighlightColor: (String, String) -> Unit, + onOpenChannelSettings: (String, String) -> Unit, onBatchApplyToEnabledChannels: (Map) -> Unit, + enableAllRequestId: Int = 0, + batchRequestId: Int = 0, + modifier: Modifier = Modifier, ) { val channels = state.channels var showBatchDialog by remember { mutableStateOf(false) } + BackHandler(enabled = showBatchDialog) { + showBatchDialog = false + } + LaunchedEffect(enableAllRequestId) { + if (enableAllRequestId > 0) onEnableAllChannels() + } + LaunchedEffect(batchRequestId) { + if (batchRequestId > 0) showBatchDialog = true + } - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text(state.packageName, style = MaterialTheme.typography.bodyMedium) + val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current + LazyColumn( + modifier = modifier + .fillMaxSize() + .overScrollVertical() + .scrollEndHaptic(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = contentPadding.calculateTopPadding() + 12.dp, + bottom = contentPadding.calculateBottomPadding() + 12.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AppIcon(icon = state.appIcon, fallbackIcon = MiuixIcons.Regular.All, size = 30.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (state.appName.isBlank()) state.packageName else state.appName, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium, + ) + Text( + state.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MiuixSwitch( + checked = state.appEnabled, + onCheckedChange = onSetAppEnabled, + ) + } + } + } if (state.loading) { - Spacer(modifier = Modifier.height(20.dp)) - MiuixCircularProgressIndicator() - return + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + MiuixCircularProgressIndicator() + } + } + return@LazyColumn } state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixButton(onClick = onRefresh) { Text("重试") } + item { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(it, color = MaterialTheme.colorScheme.error) + MiuixButton(onClick = onRefresh) { Text("重试") } + } } - return + return@LazyColumn } - Spacer(modifier = Modifier.height(10.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixButton(onClick = onEnableAllChannels) { Text("全部渠道生效") } - MiuixButton(onClick = { showBatchDialog = true }) { Text("批量应用") } + if (!state.appEnabled) { + item { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "请先开启应用总开关后再配置通知渠道", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return@LazyColumn } - Spacer(modifier = Modifier.height(8.dp)) if (channels.isEmpty()) { - Text( - "未读取到通知渠道,可尝试点击“重试”或确认 Root 权限", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(8.dp)) - MiuixButton(onClick = onRefresh) { Text("重试") } - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(channels, key = { it.id }) { channel -> - val enabled = state.enabledChannels.isEmpty() || state.enabledChannels.contains(channel.id) - val template = state.channelTemplates[channel.id] ?: "notification_island" - val timeout = state.channelTimeout[channel.id] ?: "5" - val extras = state.channelExtras[channel.id] ?: ChannelExtraSettings() - ChannelCard( - channel = channel, - enabled = enabled, - template = template, - timeout = timeout, - extras = extras, - onEnableChange = { onToggleChannel(channel.id, it) }, - onCycleTemplate = { onCycleTemplate(channel.id) }, - onSetTimeout = { onSetTimeout(channel.id, it) }, - onCycleSetting = { setting -> onCycleSetting(channel.id, setting) }, - onSetHighlightColor = { onSetHighlightColor(channel.id, it) }, + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "未读取到通知渠道", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + MiuixButton(onClick = onRefresh) { Text("刷新") } + } + } + } else { + item { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + channels.forEach { channel -> + val enabled = state.enabledChannels.isEmpty() || state.enabledChannels.contains(channel.id) + ChannelListItem( + channel = channel, + enabled = enabled, + onEnableChange = { onToggleChannel(channel.id, it) }, + onOpenSettings = { + if (enabled) onOpenChannelSettings(channel.id, channel.name) + }, + ) + } + } } } } @@ -204,6 +470,7 @@ fun AppChannelsScreen( if (showBatchDialog) { BatchApplyDialog( + title = "批量应用到已启用渠道", onDismiss = { showBatchDialog = false }, onApply = { settings -> showBatchDialog = false @@ -214,143 +481,783 @@ fun AppChannelsScreen( } @Composable -private fun ChannelCard( +fun ChannelSettingsScreen( + state: AppChannelsUiState, + channelId: String, + onRefresh: () -> Unit, + onSetTemplate: (String) -> Unit, + onSetTimeout: (String) -> Unit, + onSetSetting: (String, String) -> Unit, + onSetHighlightColor: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val channel = state.channels.firstOrNull { it.id == channelId } + val template = state.channelTemplates[channelId] ?: "notification_island" + val timeout = state.channelTimeout[channelId] ?: "5" + val extras = state.channelExtras[channelId] ?: ChannelExtraSettings() + + if (state.loading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + MiuixCircularProgressIndicator() + } + return + } + + if (state.error != null) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(state.error, color = MaterialTheme.colorScheme.error) + MiuixButton(onClick = onRefresh) { Text("重试") } + } + return + } + + if (channel == null) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("未找到该通知渠道", color = MaterialTheme.colorScheme.onSurfaceVariant) + MiuixButton(onClick = onRefresh) { Text("刷新") } + } + return + } + + val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .overScrollVertical() + .scrollEndHaptic() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = contentPadding.calculateTopPadding() + 12.dp, + bottom = contentPadding.calculateBottomPadding() + 12.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ChannelSettingsContent( + channel = channel, + template = template, + timeout = timeout, + extras = extras, + onSetTemplate = onSetTemplate, + onSetTimeout = onSetTimeout, + onSetSetting = onSetSetting, + onSetHighlightColor = onSetHighlightColor, + ) + } +} +} + +@Composable +fun AppListIcon(app: AppItem) { + AppIcon(icon = app.icon, fallbackIcon = MiuixIcons.Regular.All, size = 40.dp) +} + +@Composable +fun AppIcon(icon: ByteArray, fallbackIcon: ImageVector, size: Dp) { + val bitmap = remember(icon) { + if (icon.isEmpty()) { + null + } else { + BitmapFactory.decodeByteArray(icon, 0, icon.size) + } + } + Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size), + ) + } else { + Icon( + imageVector = fallbackIcon, + contentDescription = null, + modifier = Modifier.size(size - 8.dp), + ) + } + } +} + +@Composable +private fun ChannelListItem( channel: ChannelItem, enabled: Boolean, - template: String, - timeout: String, - extras: ChannelExtraSettings, onEnableChange: (Boolean) -> Unit, - onCycleTemplate: () -> Unit, - onSetTimeout: (String) -> Unit, - onCycleSetting: (String) -> Unit, - onSetHighlightColor: (String) -> Unit, + onOpenSettings: () -> Unit, ) { - var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Column(modifier = Modifier.weight(1f)) { Text(channel.name, fontWeight = FontWeight.SemiBold) - Text(channel.id, style = MaterialTheme.typography.bodySmall) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "重要性: ${channel.importance}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + Text( + text = channel.id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + MiuixIconButton(onClick = onOpenSettings, enabled = enabled) { + Icon( + imageVector = MiuixIcons.Regular.Settings, + contentDescription = "渠道设置", + ) } MiuixSwitch( checked = enabled, onCheckedChange = onEnableChange, ) } - Text("重要性: ${channel.importance}") if (channel.description.isNotBlank()) { Text("描述: ${channel.description}", style = MaterialTheme.typography.bodySmall) } + } + } +} + +@Composable +private fun ChannelSettingsContent( + channel: ChannelItem, + template: String, + timeout: String, + extras: ChannelExtraSettings, + onSetTemplate: (String) -> Unit, + onSetTimeout: (String) -> Unit, + onSetSetting: (String, String) -> Unit, + onSetHighlightColor: (String) -> Unit, +) { + fun triStateOptions(defaultOn: Boolean): List> = listOf( + "default" to if (defaultOn) "默认(开启)" else "默认(关闭)", + "on" to "开启", + "off" to "关闭", + ) + + val templateOptions = listOf( + "notification_island" to "通知超级岛", + "notification_island_lite" to "通知超级岛 Lite", + "download_lite" to "下载 Lite", + "ai_notification_island" to "AI 通知超级岛", + ) + val iconModeOptions = listOf( + "auto" to "自动", + "notif_small" to "通知小图标", + "notif_large" to "通知大图标", + "app_icon" to "应用图标", + ) + val showIslandIconOptions = triStateOptions(defaultOn = true) + val firstFloatOptions = triStateOptions(defaultOn = false) + val enableFloatOptions = triStateOptions(defaultOn = false) + val marqueeOptions = triStateOptions(defaultOn = false) + val focusOptions = triStateOptions(defaultOn = true) + val preserveStatusBarOptions = triStateOptions(defaultOn = false) + val restoreLockscreenOptions = triStateOptions(defaultOn = false) + val rendererOptions = listOf( + "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", + "image_text_with_buttons_4_wrap" to "封面信息样式", + "image_text_with_right_text_button" to "图文右侧文本按钮", + ) + var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } + var showColorPaletteDialog by remember { mutableStateOf(false) } + var dialogPaletteColor by remember { mutableStateOf(parseHexToColor(extras.highlightColor) ?: Color(0xFFFF3B30)) } + val paletteColor = remember(highlightDraft) { + parseHexToColor(highlightDraft) ?: Color(0xFFFF3B30) + } + + BackHandler(enabled = showColorPaletteDialog) { + showColorPaletteDialog = false + } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("模板: $template") - MiuixButton(onClick = onCycleTemplate) { - Text("切换模板") + OverlayDialog( + title = "选择高亮颜色", + show = showColorPaletteDialog, + onDismissRequest = { showColorPaletteDialog = false }, + renderInRootScaffold = false, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MiuixColorPalette( + color = dialogPaletteColor, + onColorChanged = { dialogPaletteColor = it }, + showPreview = true, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { showColorPaletteDialog = false }, + modifier = Modifier.weight(1f), + ) { + Text("取消") + } + TextButton( + onClick = { + val hex = dialogPaletteColor.toHexRgbString() + highlightDraft = hex + onSetHighlightColor(hex) + showColorPaletteDialog = false + }, + modifier = Modifier.weight(1f), + ) { + Text("确定") } } + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ChannelSectionTitle("模板") + ChannelSectionCard { + SettingsDropdownRow( + title = "模板", + options = templateOptions, + selectedValue = template, + enabled = true, + largeText = true, + onValueChange = onSetTemplate, + ) + SettingsDropdownRow("样式", rendererOptions, extras.renderer, true, largeText = true) { + onSetSetting("renderer", it) + } + } + + ChannelSectionTitle("岛") + ChannelSectionCard { + SettingsDropdownRow("超级岛图标", iconModeOptions, extras.icon, true, largeText = true) { onSetSetting("icon", it) } + SettingsDropdownRow("大岛图标", showIslandIconOptions, extras.showIslandIcon, true, largeText = true) { + onSetSetting("show_island_icon", it) + } + SettingsDropdownRow("初次展开", firstFloatOptions, extras.firstFloat, true, largeText = true) { + onSetSetting("first_float", it) + } + SettingsDropdownRow("更新展开", enableFloatOptions, extras.enableFloat, true, largeText = true) { + onSetSetting("enable_float", it) + } + SettingsDropdownRow("消息滚动", marqueeOptions, extras.marquee, true, largeText = true) { + onSetSetting("marquee", it) + } MiuixTextField( value = timeout, onValueChange = { onSetTimeout(it) }, - label = "超时秒数(1-30)", + label = "自动消失时长(1-30秒)", + useLabelAsPlaceholder = true, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), ) - SettingsCycleRow("图标来源", extras.icon) { onCycleSetting("icon") } - SettingsCycleRow("焦点图标", extras.focusIcon) { onCycleSetting("focus_icon") } - SettingsCycleRow("焦点通知", extras.focus) { onCycleSetting("focus") } - SettingsCycleRow("保留状态栏小图标", extras.preserveSmallIcon) { onCycleSetting("preserve_small_icon") } - SettingsCycleRow("显示岛图标", extras.showIslandIcon) { onCycleSetting("show_island_icon") } - SettingsCycleRow("首次展开", extras.firstFloat) { onCycleSetting("first_float") } - SettingsCycleRow("更新展开", extras.enableFloat) { onCycleSetting("enable_float") } - SettingsCycleRow("跑马灯", extras.marquee) { onCycleSetting("marquee") } - SettingsCycleRow("渲染器", extras.renderer) { onCycleSetting("renderer") } - SettingsCycleRow("锁屏恢复", extras.restoreLockscreen) { onCycleSetting("restore_lockscreen") } - SettingsCycleRow("左侧高亮", extras.showLeftHighlight) { onCycleSetting("show_left_highlight") } - SettingsCycleRow("右侧高亮", extras.showRightHighlight) { onCycleSetting("show_right_highlight") } - MiuixTextField( - value = highlightDraft, - onValueChange = { - highlightDraft = it - onSetHighlightColor(it) - }, - label = "高亮颜色(#RRGGBB,可空)", - singleLine = true, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MiuixTextField( + value = highlightDraft, + onValueChange = { + highlightDraft = it + onSetHighlightColor(it) + }, + label = "高亮颜色(#RRGGBB,可空)", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.weight(1f), + ) + MiuixButton( + onClick = { + dialogPaletteColor = paletteColor + showColorPaletteDialog = true + }, + ) { + Text("调色盘") + } + } + SwitchSettingRow( + title = "左侧高亮", + checked = extras.showLeftHighlight == "on", + onCheckedChange = { onSetSetting("show_left_highlight", if (it) "on" else "off") }, + ) + SwitchSettingRow( + title = "右侧高亮", + checked = extras.showRightHighlight == "on", + onCheckedChange = { onSetSetting("show_right_highlight", if (it) "on" else "off") }, ) } + + ChannelSectionTitle("焦点通知") + ChannelSectionCard { + SettingsDropdownRow("焦点图标", iconModeOptions, extras.focusIcon, true, largeText = true) { + onSetSetting("focus_icon", it) + } + SettingsDropdownRow("焦点通知", focusOptions, extras.focus, true, largeText = true) { onSetSetting("focus", it) } + SettingsDropdownRow( + "状态栏图标", + preserveStatusBarOptions, + extras.preserveSmallIcon, + extras.focus != "off", + largeText = true, + ) { onSetSetting("preserve_small_icon", it) } + SettingsDropdownRow("锁屏通知恢复", restoreLockscreenOptions, extras.restoreLockscreen, true, largeText = true) { + onSetSetting("restore_lockscreen", it) + } + } } } @Composable -private fun SettingsCycleRow(title: String, value: String, onCycle: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("$title: $value", modifier = Modifier.weight(1f)) - MiuixButton(onClick = onCycle) { Text("切换") } +private fun ChannelSectionTitle(title: String) { + MiuixSmallTitle( + text = title, + insideMargin = PaddingValues(horizontal = 12.dp, vertical = 2.dp), + ) +} + +@Composable +private fun ChannelSectionCard(content: @Composable () -> Unit) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + content() + } } } +private fun parseHexToColor(value: String): Color? { + val text = value.trim() + if (text.isBlank()) return null + val normalized = if (text.startsWith("#")) text else "#$text" + return runCatching { Color(android.graphics.Color.parseColor(normalized)) }.getOrNull() +} + +private fun Color.toHexRgbString(): String { + val r = (red * 255f).toInt().coerceIn(0, 255) + val g = (green * 255f).toInt().coerceIn(0, 255) + val b = (blue * 255f).toInt().coerceIn(0, 255) + return "#%02X%02X%02X".format(r, g, b) +} + +@Composable +private fun SettingsDropdownRow( + title: String, + options: List>, + selectedValue: String, + enabled: Boolean, + largeText: Boolean = false, + onValueChange: (String) -> Unit, +) { + val selectedIndex = options.indexOfFirst { it.first == selectedValue }.coerceAtLeast(0) + MiuixOverlayDropdownPreference( + title = title, + summary = if (largeText) null else options.getOrNull(selectedIndex)?.second, + items = options.map { it.second }, + selectedIndex = selectedIndex, + renderInRootScaffold = false, + onSelectedIndexChange = { index -> + val value = options.getOrNull(index)?.first ?: return@MiuixOverlayDropdownPreference + onValueChange(value) + }, + enabled = enabled, + ) +} + @Composable private fun BatchApplyDialog( + title: String, onDismiss: () -> Unit, onApply: (Map) -> Unit, ) { - var template by remember { mutableStateOf("notification_island") } - var timeout by remember { mutableStateOf("5") } - var focus by remember { mutableStateOf("default") } - MiuixOverlayDialog( + val noChange = "__NO_CHANGE__" + val triStateOptions = listOf( + noChange to "不更改", + "default" to "默认", + "on" to "开启", + "off" to "关闭", + ) + val iconModeOptions = listOf( + noChange to "不更改", + "auto" to "自动", + "notif_small" to "通知小图标", + "notif_large" to "通知大图标", + "app_icon" to "应用图标", + ) + val templateOptions = listOf( + noChange to "不更改", + "notification_island" to "通知超级岛", + "notification_island_lite" to "通知超级岛 Lite", + "download_lite" to "下载 Lite", + "ai_notification_island" to "AI 通知超级岛", + ) + val rendererOptions = listOf( + noChange to "不更改", + "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", + "image_text_with_buttons_4_wrap" to "封面信息样式", + "image_text_with_right_text_button" to "图文右侧文本按钮", + ) + + var template by remember { mutableStateOf(noChange) } + var renderer by remember { mutableStateOf(noChange) } + var timeout by remember { mutableStateOf("") } + var icon by remember { mutableStateOf(noChange) } + var focusIcon by remember { mutableStateOf(noChange) } + var focus by remember { mutableStateOf(noChange) } + var preserveSmallIcon by remember { mutableStateOf(noChange) } + var showIslandIcon by remember { mutableStateOf(noChange) } + var firstFloat by remember { mutableStateOf(noChange) } + var enableFloat by remember { mutableStateOf(noChange) } + var marquee by remember { mutableStateOf(noChange) } + var restoreLockscreen by remember { mutableStateOf(noChange) } + var showLeftHighlight by remember { mutableStateOf(false) } + var showRightHighlight by remember { mutableStateOf(false) } + var highlightColor by remember { mutableStateOf("") } + OverlayBottomSheet( show = true, - title = "批量应用到已启用渠道", - summary = "", + title = title, onDismissRequest = onDismiss, onDismissFinished = {}, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixTextField( - value = template, - onValueChange = { template = it }, - label = "模板ID", - singleLine = true, - ) - MiuixTextField( - value = timeout, - onValueChange = { timeout = it }, - label = "超时(1-30)", - singleLine = true, + startAction = { + MiuixIconButton(onClick = onDismiss) { + FaIcon( + glyph = FaGlyph.Times, + contentDescription = "取消", ) - MiuixTextField( - value = focus, - onValueChange = { focus = it }, - label = "焦点通知(default/on/off)", - singleLine = true, + } + }, + endAction = { + MiuixIconButton( + onClick = { + val settings = mutableMapOf() + fun putIfChanged(key: String, value: String) { + if (value != noChange) settings[key] = value + } + + putIfChanged("template", template) + putIfChanged("renderer", renderer) + putIfChanged("icon", icon) + putIfChanged("focus_icon", focusIcon) + putIfChanged("focus", focus) + putIfChanged("preserve_small_icon", preserveSmallIcon) + putIfChanged("show_island_icon", showIslandIcon) + putIfChanged("first_float", firstFloat) + putIfChanged("enable_float", enableFloat) + putIfChanged("marquee", marquee) + putIfChanged("restore_lockscreen", restoreLockscreen) + settings["show_left_highlight"] = if (showLeftHighlight) "on" else "off" + settings["show_right_highlight"] = if (showRightHighlight) "on" else "off" + + val normalizedTimeout = timeout.trim().toIntOrNull()?.coerceIn(1, 30)?.toString() + if (!normalizedTimeout.isNullOrEmpty()) { + settings["timeout"] = normalizedTimeout + } + if (highlightColor.trim().isNotEmpty()) { + settings["highlight_color"] = highlightColor.trim() + } + + onApply(settings) + }, + ) { + FaIcon( + glyph = FaGlyph.Check, + contentDescription = "应用", ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { - Text("取消") + }, + renderInRootScaffold = false, + content = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .overScrollVertical() + .scrollEndHaptic(), + ) { + ChannelSectionTitle("模板") + ChannelSectionCard { + SettingsDropdownRow("模板", templateOptions, template, true, largeText = true) { template = it } + SettingsDropdownRow("样式", rendererOptions, renderer, true, largeText = true) { renderer = it } } - MiuixButton( - onClick = { - onApply( - mapOf( - "template" to template.ifBlank { "notification_island" }, - "timeout" to (timeout.toIntOrNull()?.coerceIn(1, 30)?.toString() ?: "5"), - "focus" to focus.ifBlank { "default" }, - ), - ) - }, - modifier = Modifier.weight(1f), + + ChannelSectionTitle("岛") + ChannelSectionCard { + SettingsDropdownRow("超级岛图标", iconModeOptions, icon, true, largeText = true) { icon = it } + SettingsDropdownRow("大岛图标", triStateOptions, showIslandIcon, true, largeText = true) { + showIslandIcon = it + } + SettingsDropdownRow("初次展开", triStateOptions, firstFloat, true, largeText = true) { + firstFloat = it + } + SettingsDropdownRow("更新展开", triStateOptions, enableFloat, true, largeText = true) { + enableFloat = it + } + SettingsDropdownRow("消息滚动", triStateOptions, marquee, true, largeText = true) { + marquee = it + } + MiuixTextField( + value = timeout, + onValueChange = { timeout = it }, + label = "自动消失时长(1-30秒,留空不改)", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + ) + MiuixTextField( + value = highlightColor, + onValueChange = { highlightColor = it }, + label = "高亮颜色(#RRGGBB,留空不改)", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + ) + SwitchSettingRow( + title = "左侧高亮", + checked = showLeftHighlight, + onCheckedChange = { showLeftHighlight = it }, + ) + SwitchSettingRow( + title = "右侧高亮", + checked = showRightHighlight, + onCheckedChange = { showRightHighlight = it }, + ) + } + + ChannelSectionTitle("焦点通知") + ChannelSectionCard { + SettingsDropdownRow("焦点图标", iconModeOptions, focusIcon, true, largeText = true) { + focusIcon = it + } + SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { focus = it } + SettingsDropdownRow("状态栏图标", triStateOptions, preserveSmallIcon, true, largeText = true) { + preserveSmallIcon = it + } + SettingsDropdownRow("锁屏通知恢复", triStateOptions, restoreLockscreen, true, largeText = true) { + restoreLockscreen = it + } + } + } + }, + ) +} + +@Composable +private fun AppItemRow( + app: AppItem, + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit, + onClick: () -> Unit, + selectionMode: Boolean, + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, +) { + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(18.dp)) + .pressable(interactionSource = remember { MutableInteractionSource() }) + .clickable { onClick() }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + AppListIcon(app = app) + Spacer(modifier = Modifier.size(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(app.appName, fontWeight = FontWeight.SemiBold) + Text(app.packageName, style = MaterialTheme.typography.bodySmall) + } + if (selectionMode) { + MiuixCheckbox( + state = if (selected) ToggleableState.On else ToggleableState.Off, + onClick = { onSelectedChange(!selected) }, + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text("应用") + MiuixSwitch( + checked = enabled, + onCheckedChange = { onEnabledChange(it) }, + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = "进入渠道设置", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } } } + +@Composable +private fun SwitchSettingRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun AppsScreenPreview() { + MiuixTheme { + MaterialTheme { + AppsScreen( + state = AppsUiState( + loading = false, + query = "", + showSystemApps = true, + apps = listOf( + AppItem("com.miui.home", "系统桌面", true), + AppItem("com.tencent.mm", "微信", false), + AppItem("com.ss.android.ugc.aweme", "抖音", false), + ), + enabledPackages = setOf("com.tencent.mm"), + ), + onRefresh = {}, + onQueryChange = {}, + onAppEnabledChange = { _, _ -> }, + onOpenAppChannels = {}, + onBatchApplyGlobal = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun AppChannelsScreenPreview() { + MiuixTheme { + MaterialTheme { + AppChannelsScreen( + state = AppChannelsUiState( + packageName = "com.tencent.mm", + appName = "微信", + appEnabled = true, + loading = false, + channels = listOf( + ChannelItem("chat_msg", "聊天消息", "收到新消息时通知", 4), + ChannelItem("pay", "支付通知", "收付款结果提醒", 4), + ), + enabledChannels = setOf("chat_msg", "pay"), + ), + onRefresh = {}, + onSetAppEnabled = {}, + onToggleChannel = { _, _ -> }, + onEnableAllChannels = {}, + onOpenChannelSettings = { _, _ -> }, + onBatchApplyToEnabledChannels = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun ChannelSettingsScreenPreview() { + val channelId = "chat_msg" + MiuixTheme { + MaterialTheme { + ChannelSettingsScreen( + state = AppChannelsUiState( + packageName = "com.tencent.mm", + appName = "微信", + appEnabled = true, + loading = false, + channels = listOf( + ChannelItem(channelId, "聊天消息", "收到新消息时通知", 4), + ), + enabledChannels = setOf(channelId), + channelTemplates = mapOf(channelId to "notification_island"), + channelTimeout = mapOf(channelId to "5"), + channelExtras = mapOf( + channelId to ChannelExtraSettings( + icon = "app_icon", + focusIcon = "notif_small", + focus = "on", + preserveSmallIcon = "off", + showIslandIcon = "on", + firstFloat = "off", + enableFloat = "on", + marquee = "on", + renderer = "image_text_with_buttons_4", + restoreLockscreen = "off", + highlightColor = "#00C2FF", + showLeftHighlight = "on", + showRightHighlight = "off", + ), + ), + ), + channelId = channelId, + onRefresh = {}, + onSetTemplate = {}, + onSetTimeout = {}, + onSetSetting = { _, _ -> }, + onSetHighlightColor = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt index f8f883c9..23e22ed9 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsUiState.kt @@ -4,6 +4,7 @@ data class AppItem( val packageName: String, val appName: String, val isSystem: Boolean, + val icon: ByteArray = byteArrayOf(), ) data class AppsUiState( @@ -12,6 +13,8 @@ data class AppsUiState( val query: String = "", val showSystemApps: Boolean = false, val apps: List = emptyList(), + val filteredApps: List = emptyList(), val enabledPackages: Set = emptySet(), + val selectedPackages: Set = emptySet(), val error: String? = null, ) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt index 5a504c33..5ad12385 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsViewModel.kt @@ -3,6 +3,8 @@ package io.github.hyperisland.ui.app import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -11,9 +13,11 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext class AppsViewModel(app: Application) : AndroidViewModel(app) { private val repo = AppAdaptationRepository(app) + private var iconLoadJob: Job? = null private val _uiState = MutableStateFlow(AppsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -25,11 +29,12 @@ class AppsViewModel(app: Application) : AndroidViewModel(app) { } fun refresh() { + iconLoadJob?.cancel() viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } val enabled = repo.loadEnabledPackages() runCatching { - repo.loadInstalledApps() + repo.loadInstalledApps(includeIcons = false) }.onSuccess { apps -> _uiState.update { it.copy( @@ -38,6 +43,10 @@ class AppsViewModel(app: Application) : AndroidViewModel(app) { enabledPackages = enabled, ) } + updateFilteredApps() + iconLoadJob = viewModelScope.launch { + preloadIconsInBackground(apps) + } }.onFailure { e -> _uiState.update { it.copy( @@ -50,12 +59,76 @@ class AppsViewModel(app: Application) : AndroidViewModel(app) { } } + private suspend fun preloadIconsInBackground(apps: List) { + if (apps.isEmpty()) return + val updated = apps.toMutableList() + val indexByPackage = apps.mapIndexed { index, app -> app.packageName to index }.toMap() + val state = _uiState.value + val prioritizedPackages = filterApps( + apps = apps, + query = state.query, + showSystemApps = state.showSystemApps, + alwaysVisiblePackages = state.enabledPackages, + prioritizedPackages = state.enabledPackages, + ).map { it.packageName } + val remainingPackages = apps.asSequence() + .map { it.packageName } + .filterNot { it in prioritizedPackages } + .toList() + val packagesToLoad = prioritizedPackages + remainingPackages + + suspend fun publishBatch(packageNames: List) { + coroutineContext.ensureActive() + if (packageNames.isEmpty()) return + val icons = repo.loadAppIcons(packageNames) + if (icons.isEmpty()) return + var changed = false + icons.forEach { (packageName, icon) -> + val index = indexByPackage[packageName] ?: return@forEach + if (!updated[index].icon.contentEquals(icon)) { + updated[index] = updated[index].copy(icon = icon) + changed = true + } + } + if (changed) { + val snapshot = updated.toList() + _uiState.update { current -> + if (current.apps.isEmpty()) current else current.copy(apps = snapshot) + } + updateFilteredApps() + } + } + + publishBatch(packagesToLoad.take(INITIAL_ICON_BATCH_SIZE)) + packagesToLoad + .drop(INITIAL_ICON_BATCH_SIZE) + .chunked(ICON_BATCH_SIZE) + .forEach { batch -> + publishBatch(batch) + } + } + fun setQuery(value: String) { _uiState.update { it.copy(query = value) } + updateFilteredApps() } fun setShowSystemApps(value: Boolean) { _uiState.update { it.copy(showSystemApps = value) } + updateFilteredApps() + } + + private fun updateFilteredApps() { + _uiState.update { state -> + val filtered = filterApps( + apps = state.apps, + query = state.query, + showSystemApps = state.showSystemApps, + alwaysVisiblePackages = state.enabledPackages, + prioritizedPackages = state.enabledPackages, + ) + state.copy(filteredApps = filtered) + } } fun setEnabled(packageName: String, enabled: Boolean) { @@ -64,6 +137,7 @@ class AppsViewModel(app: Application) : AndroidViewModel(app) { }.toSet() repo.setEnabledPackages(next) _uiState.update { it.copy(enabledPackages = next) } + updateFilteredApps() } fun batchApplyToAllEnabledApps(settings: Map) { @@ -83,4 +157,56 @@ class AppsViewModel(app: Application) : AndroidViewModel(app) { _events.emit("全局批量应用完成($enabledCount 个应用)") } } + + fun batchApplyToSelectedApps(packages: Set, settings: Map) { + val selected = packages.filter { it.isNotBlank() } + if (selected.isEmpty()) { + viewModelScope.launch { _events.emit("请先选择应用") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(applying = true) } + val total = selected.size + selected.forEachIndexed { index, pkg -> + runCatching { + val channels = repo.loadChannels(pkg).orEmpty() + if (channels.isEmpty()) return@runCatching + val enabledChannels = repo.getEnabledChannels(pkg) + val ids = if (enabledChannels.isEmpty()) { + channels.map { it.id } + } else { + enabledChannels.toList() + } + if (ids.isNotEmpty()) { + repo.batchApplyChannelSettings(pkg, ids, settings) + } + } + _events.emit("批量进度: ${index + 1}/$total") + } + _uiState.update { it.copy(applying = false) } + _events.emit("已对 $total 个已选应用应用渠道配置") + } + } + + fun setSelectedPackages(packages: Set) { + _uiState.update { it.copy(selectedPackages = packages) } + } + + fun toggleSelectedPackage(packageName: String) { + _uiState.update { state -> + val next = state.selectedPackages.toMutableSet().apply { + if (contains(packageName)) remove(packageName) else add(packageName) + } + state.copy(selectedPackages = next) + } + } + + fun clearSelectedPackages() { + _uiState.update { it.copy(selectedPackages = emptySet()) } + } + + private companion object { + const val INITIAL_ICON_BATCH_SIZE = 24 + const val ICON_BATCH_SIZE = 48 + } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt index 40e93a00..96b98d02 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt @@ -1,7 +1,12 @@ package io.github.hyperisland.ui.blacklist +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -10,98 +15,238 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.hyperisland.ui.app.AppIcon +import io.github.hyperisland.ui.app.AppItem +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.All +import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.Card as MiuixCard import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch -import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.pressable +import top.yukonga.miuix.kmp.utils.scrollEndHaptic @Composable fun BlacklistScreen( state: BlacklistUiState, onRefresh: () -> Unit, onQueryChange: (String) -> Unit, - onShowSystemChange: (Boolean) -> Unit, onSetBlacklisted: (String, Boolean) -> Unit, - onEnableAllVisible: () -> Unit, - onDisableAllVisible: () -> Unit, - onApplyGamePreset: () -> Unit, + canPullToRefresh: Boolean = false, + modifier: Modifier = Modifier, ) { - val filtered = state.apps.filter { app -> - val matchSystem = state.showSystemApps || !app.isSystem || state.blacklistedPackages.contains(app.packageName) - val q = state.query.trim().lowercase() - val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) - matchSystem && matchQuery - } + val pullToRefreshState = rememberPullToRefreshState() + val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current + val topPadding = contentPadding.calculateTopPadding() + val listTranslationY = if (canPullToRefresh) -topPadding else 0.dp - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - MiuixTextField( - value = state.query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - label = "搜索应用 / 包名", - singleLine = true, - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + val listContent: @Composable () -> Unit = { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = listTranslationY.toPx() + clip = false + } + .overScrollVertical() + .scrollEndHaptic(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = contentPadding.calculateTopPadding() + 8.dp, + bottom = contentPadding.calculateBottomPadding() + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("显示系统应用") - MiuixSwitch(checked = state.showSystemApps, onCheckedChange = onShowSystemChange) - } + if (state.loading && state.apps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + MiuixCircularProgressIndicator() + } + } + } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MiuixButton(onClick = onApplyGamePreset) { Text("游戏预设") } - MiuixButton(onClick = onEnableAllVisible) { Text("全部加入") } - MiuixButton(onClick = onDisableAllVisible) { Text("全部移除") } - MiuixButton(onClick = onRefresh) { Text("刷新") } - } + state.error?.let { + item { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) - Text("黑名单应用:${state.blacklistedPackages.size}") + if (!state.loading && state.filteredApps.isEmpty()) { + item { + EmptyBlacklistState( + query = state.query, + onClearQuery = { onQueryChange("") }, + ) + } + } + items(state.filteredApps, key = { it.packageName }) { app -> + val enabled = state.blacklistedPackages.contains(app.packageName) + BlacklistAppRow( + app = app, + enabled = enabled, + onEnabledChange = { onSetBlacklisted(app.packageName, it) }, + ) + } + } + } - if (state.loading) { - Spacer(modifier = Modifier.height(20.dp)) - MiuixCircularProgressIndicator() - return + Box(modifier = modifier.fillMaxSize()) { + if (canPullToRefresh) { + MiuixPullToRefresh( + isRefreshing = state.loading, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = if (canPullToRefresh) topPadding.toPx() else 0f + clip = false + }, + pullToRefreshState = pullToRefreshState, + refreshTexts = listOf("下拉刷新", "松开刷新", "正在刷新..."), + ) { + listContent() + } + } else { + listContent() } + } +} - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(it, color = MaterialTheme.colorScheme.error) +@Composable +private fun EmptyBlacklistState( + query: String, + onClearQuery: () -> Unit, +) { + MiuixCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = if (query.isBlank()) "没有可显示的应用" else "没有匹配的应用", + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (query.isBlank()) "可以尝试显示系统应用或下拉刷新。" else "换个关键词试试。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (query.isNotBlank()) { + MiuixButton(onClick = onClearQuery) { + Text("清空搜索") + } + } } + } +} - Spacer(modifier = Modifier.height(8.dp)) - LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(filtered, key = { it.packageName }) { app -> - val enabled = state.blacklistedPackages.contains(app.packageName) - MiuixCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(app.appName, fontWeight = FontWeight.SemiBold) - Text(app.packageName, style = MaterialTheme.typography.bodySmall) - } - MiuixSwitch( - checked = enabled, - onCheckedChange = { onSetBlacklisted(app.packageName, it) }, - ) - } +@Composable +private fun BlacklistAppRow( + app: AppItem, + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit, +) { + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .pressable(interactionSource = remember { MutableInteractionSource() }) + .clickable { onEnabledChange(!enabled) }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(icon = app.icon, fallbackIcon = MiuixIcons.Regular.All, size = 40.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.appName, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + if (app.isSystem) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "系统应用", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + MiuixSwitch( + checked = enabled, + onCheckedChange = onEnabledChange, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun BlacklistScreenPreview() { + MiuixTheme { + MaterialTheme { + BlacklistScreen( + state = BlacklistUiState( + loading = false, + query = "", + showSystemApps = false, + apps = listOf( + AppItem("com.tencent.mm", "微信", false), + AppItem("com.ss.android.ugc.aweme", "抖音", false), + AppItem("com.miui.weather2", "天气", true), + ), + filteredApps = listOf( + AppItem("com.ss.android.ugc.aweme", "抖音", false), + AppItem("com.tencent.mm", "微信", false), + AppItem("com.miui.weather2", "天气", true), + ), + blacklistedPackages = setOf("com.ss.android.ugc.aweme"), + ), + onRefresh = {}, + onQueryChange = {}, + onSetBlacklisted = { _, _ -> }, + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt index 6a2ce5b8..671fb5fe 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistUiState.kt @@ -7,6 +7,7 @@ data class BlacklistUiState( val query: String = "", val showSystemApps: Boolean = false, val apps: List = emptyList(), + val filteredApps: List = emptyList(), val blacklistedPackages: Set = emptySet(), val error: String? = null, ) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt index 662899a9..a075d260 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistViewModel.kt @@ -3,6 +3,7 @@ package io.github.hyperisland.ui.blacklist import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import io.github.hyperisland.ui.app.filterApps import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -34,6 +35,7 @@ class BlacklistViewModel(app: Application) : AndroidViewModel(app) { _uiState.update { it.copy(loading = false, apps = apps, blacklistedPackages = blacklisted) } + updateFilteredApps() } .onFailure { e -> _uiState.update { @@ -45,10 +47,12 @@ class BlacklistViewModel(app: Application) : AndroidViewModel(app) { fun setQuery(query: String) { _uiState.update { it.copy(query = query) } + updateFilteredApps() } fun setShowSystemApps(value: Boolean) { _uiState.update { it.copy(showSystemApps = value) } + updateFilteredApps() } fun setBlacklisted(packageName: String, enabled: Boolean) { @@ -57,20 +61,23 @@ class BlacklistViewModel(app: Application) : AndroidViewModel(app) { }.toSet() repo.saveBlacklistedPackages(next) _uiState.update { it.copy(blacklistedPackages = next) } + updateFilteredApps() } fun enableAllVisible() { - val visible = currentVisibleApps().map { it.packageName } + val visible = _uiState.value.filteredApps.map { it.packageName } val next = _uiState.value.blacklistedPackages.toMutableSet().apply { addAll(visible) }.toSet() repo.saveBlacklistedPackages(next) _uiState.update { it.copy(blacklistedPackages = next) } + updateFilteredApps() } fun disableAllVisible() { - val visible = currentVisibleApps().map { it.packageName }.toSet() + val visible = _uiState.value.filteredApps.map { it.packageName }.toSet() val next = _uiState.value.blacklistedPackages.toMutableSet().apply { removeAll(visible) }.toSet() repo.saveBlacklistedPackages(next) _uiState.update { it.copy(blacklistedPackages = next) } + updateFilteredApps() } fun applyGamePreset() { @@ -79,17 +86,24 @@ class BlacklistViewModel(app: Application) : AndroidViewModel(app) { if (added > 0) { repo.saveBlacklistedPackages(next) _uiState.update { it.copy(blacklistedPackages = next) } + updateFilteredApps() } viewModelScope.launch { _events.emit("已新增 $added 个游戏到黑名单") } } - private fun currentVisibleApps() = _uiState.value.apps.filter { app -> - val state = _uiState.value - val matchSystem = state.showSystemApps || !app.isSystem || state.blacklistedPackages.contains(app.packageName) - val q = state.query.trim().lowercase() - val matchQuery = q.isBlank() || app.appName.lowercase().contains(q) || app.packageName.lowercase().contains(q) - matchSystem && matchQuery + private fun updateFilteredApps() { + _uiState.update { state -> + state.copy( + filteredApps = filterApps( + apps = state.apps, + query = state.query, + showSystemApps = state.showSystemApps, + alwaysVisiblePackages = state.blacklistedPackages, + prioritizedPackages = state.blacklistedPackages, + ), + ) + } } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt index 1144e4b9..e5ed05f2 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt @@ -47,6 +47,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { PrefKeys.ROUND_ICON -> it.copy(roundIcon = value) PrefKeys.MARQUEE_FEATURE -> it.copy(marqueeFeature = value) PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED -> it.copy(bigIslandMaxWidthEnabled = value) + PrefKeys.USE_FLOATING_NAVIGATION_BAR -> it.copy(useFloatingNavigationBar = value) PrefKeys.UNLOCK_ALL_FOCUS -> it.copy(unlockAllFocus = value) PrefKeys.UNLOCK_FOCUS_AUTH -> it.copy(unlockFocusAuth = value) PrefKeys.DEFAULT_FIRST_FLOAT -> it.copy(defaultFirstFloat = value) diff --git a/android/app/src/main/res/font/fa_regular_400.ttf b/android/app/src/main/res/font/fa_regular_400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d75d4c6a605facf886e8b6b38f2e3c46ed7c5ce6 GIT binary patch literal 68064 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/fa_solid_900.ttf b/android/app/src/main/res/font/fa_solid_900.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a0414182dca34041fdd585c0c4b4bd7a3a2753e2 GIT binary patch literal 426112 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

%HINjzu*e zxJmRui%ZN#mRQ^r`jExdwmocd)951>SKGhTqN~ZsqZT)d>Ue|3XXJ5+i|4DI};sc(YvxC zA&XukhLl^po6!o3u?J#<#?O#R7KeHoInGAv)OAoJeiRvw(zl9?K$$BV$6-@{<1ugn z_89bPxCWbJYowixi?BJq#=9+|d5LF5*Hw+nEOH#W!Xi`A7c4^i8(*>r?QCQ`6xMdq zNX#k1ywXVADpH4j4b9jmi8L{GikyNHTTNrJ>EEWPMb}?Vhg)?0)uj1A*IrG!4Z8kn zBDNG=e>GiV(e+o;T;P~>?bSs8D!Sfk;&_|x!9Ew|Tvl|Q)kJJ5y2fgH*`n*Lrd1Z9 zADiB`=sK(EBa5!Fnm)1U8ms9ui>|Snezb_TtJNZN(2PYcMYmYwGLiM2EpjTdUS(D z=A++R9?bdnId|H(Zlbtv*DRDFTmi#}wL`%uQS`3chNQQFz8WBM@qtVMMH zbFiB9M^TQu`Ah8A(MI6-it=y#Ul^pTMA(ib}L!~yE0dF zLx;nm*yZS9Fa?`8Jm`yvCl$p0b<)Tubs zzkzxchuGM_m~CJjoEt>q9BSOfW~}4%fuh%lxDNs8MI$i?Zu^F#; znZ-K@?O|~qM0;A?g=jC>f&5F*J{Io;ls;9wlh6u_^9b74;$DGPT6Db~?+<(7hklAj zEqa}bkAv}~{}tWO;&SZq{VjSOj2{4pkmp@=5->i_N9a_G^AGfRi}MAl?E{CoFJ1?y zVK<_Gu;{fTKHZ|%j`-;oy_Ut#fV0U@48~`|W!S`EJZ5n=qgMg#a(+dxwdixGIOC_d z#7A6@0bB=V{1h`TI)1>*#uC2~Zo<|vTmX!?#E`l;}2Ne15h2;CrQ`wUk=Y;SD+2>K6XEJ4g4K@7xYv35_=e`eFrAS z)?Ph0f4W;|nYIp%oQ;?ll&8*Cx} zfg%ZfCFl>m7fy7tc+=6&z;U}%&?1XoyAvS@lTNH8N}v>*7)f-qcz;BRf5oAmL=TIz z5$z51je8>6*TOrSlBl$})6gmyK%P_4YKx9_VjwV9uC{HkMb|co9W5?%Zek~ku5A)S zfc|!wXA;9;6!y937@&{c3(-AcFYHUuDD01YDayDi?p5dnI0Snxs(myWn>b4xY2n$O zB#we(NMC?zztB&5jwkAX<8T+D+Ap)Q??tZ!e7lTq;%b#GfepB&ualcR8BFU4i|y#na;^J`&H8=Q(sG zyoCJ%+5qohvz>Sk-p76mU2Adwihgb}HgiV;+_%v$EGB-|VSh`}A*8GCNfv!pkUR{IC%p)rVexiA ziNEB9r1wE*TRg4*BDk1zwJ))F^na2VQC#&y%qdH_< z-fGcjk4fT35$&71EnW>uoG1?ClO$e}_fmE+S`Wmgw-fpZ5QE;XC~=c~9=jHO!QyHE z{n?`50VQ9w7@N3Bf;S3%*<$Qhu)(9>ldoEg-GB{xZB4!oZ;)pU`liLx@ge4t#G%JH zCD#D4 zXz@7jk{Tc2G5)ReZ)<>k7)pG!GH%|HXobZ)2CcMs$D;JF;vI($w>T?Nj_Whil` zc=S_>xJm7SO+Tf^TD-aF-WGlClp1HzXG$shQPF2gsr@YaJSs&zDf&DrMXV_LePoKc zLGd_mQd@x2*6(c;~Lo&+b8ek;oODc(Yq@k^bBeLFf6 z=zH%Tl)hHHdr|r{MgMvCq1rE?*Zq|C#pR?wfNEdR2i_7?k6+{DVN}}<-eah?l{R{h zqr^^%`n@O6JK#?2XHcyVycOvE7SlG(2Rf&vp0((=#i^C>BIz%nufVI=Y^ONyQ*U6u zjIOeHuc3djc)I^Rc%MA4qn!7O_a^!&e1WayzJ#x^|AwxIud!F78!RR^Hi@J=la8-+ z4;X??x%5yA-^fXNXBb9$8YP|-zNeG)a2Q4UMwFOR^t+q%7>JVoD>}}i^KE(`U_AXo z^fvet_R%O~lYRjEWONBUg3TDG>G$;0*k_>60{!ZrhthXx`p##J)AU!GzVk0c>7(>p z*qj&XzrwrNSEBS)n&a~s*Yq0r5Sv&{e`fKC$@D)g{*CB5i_dtcwJ+#HpO{QHS$vKm z-E8r1MZdB5dQ9J2eBvYhlf}Os-3;`He}_oMvG|Kn`bF{YLQ5?E-6;L5_{4K&2aCTL z?Pu}nyG(zJe=j-^29ck>%&!Td|0p`%qR+ZB z2UvV!K68-8UxprP@t;5sv-nSQdgdI9&lqPIZ^eHVy};r#=9$@W5$T*48OA;{2m1~5a*MwTy#nTvPJCpD zgAC*Ezk}*{fX{i6(ea?aec~lk5A>h^cU1cb{EtxWBk(^)_4vX61l41B0sB+*C5t|% z&1hS}{~Xo!flsVuI9D^Y%l`s>+v5Kd{lMaXiE7>8e}!t@;5VXLANWluF|7FOQLO`f z&hw1=!iV3C>ORtZ&ihOb|6A-F{=cw)fFH^89U6xuwx(^e_&=kYExzvGDv}lQB+#x< zgx!jUEq)p;gC3-3&|Vh54Xw2J8_@w4pL()`VMp@+f)26xzoNu&c30B3pudAXc_vBS z*{CJ((0wg|j}o^^K&I@$mY@JV42~eZ5IxEgbU}}W<4D)zH~~(?4xy)8f?|}|%+4Ua z1U(04Vs}H&gY&WVSc$vrrPw{u7!Y&%{ZaNRAohYj=ru48y8@kW3HqV8!9voj&^s)F z_RV5TP>tRP_2e0dK41wV=n{CC^cs}d%s!626H07mIWL2q(dXcKZ2B?#XLu327JbDM zj6h$52GU2OZ&-rS=qlj63C5uBz~8WUM_0r9*g7U_Ec$&%_CrgsH%e?~|3RK8+6)`8 z$D==30>&x(vn7~-CZUx)2cjB75d0n`*py%*x(SGpfU(sdcq_r7DC6D6*ankO##Pbh zPHl{Zt42(%a&+h8)<%@Q1i_JE$GAB{4WN^lI?*Ag6y((i5bcR&oa z?P>{5MCl79s6!931Sg>!gA$yKYFi;V1=V&yFbyRRmEcrV_d#$PdX^>l1A37qn2ufo z8hgZS8*$fmCH4$-E?kR!HhPOCI1jzk5}c1R=54Q&XBN8J5?p|OW(h7tKeq(4(H4u| z)3yC<3A8O+Ey2a&BrZ3IUm#9vCxZk$L7{(6M$}YYpQ41s!e>_eFHHL4qmh z?+xPWaU5b0|9bRLgSDq)GY-L#=otp_2cxqL`ktNBdZ9tQ_t4n}@uTP!266s{UTF~b z3-mUFxKE*X8gyOfwBBV9?m?W^dkx|&Lhmz(t3Dnvi1P%x)F6I<>h=@NrP_zj7{vP? zea;|`*4bbXFNVHu5S~prt#244*b{xzAbtvc%OL)#sP^MuSYzwBzirUjYt)@3eko5N`qcu|c@jIIW)=#J?K-+#swyoL23lFF2N0&_;uv?@sG_ zgSdKpEe2ilJFVXu#Md#^WB#798h3gODG{Epo0ex=lr0zCY z`yT9ja_QPWu01Bt{n+=@&L~R%g1GOa#2AS44*IA;p9454t$P{u5>u(=24S9bQj7<% z1J(Y07C%MkO9p+`>ZD#ah}#!^)gW#Jea#>qW0-0%Sey7vF?aE-)#08=5uUX=+&3x0 zb4VxkmO*$M)!`mWvG%*z@6w#((A5TgX6|rrrHH3vsK@y}`3IpN7{u>_>KJ@XU3!ef zCy1xgI-gdRHH%Mr%^56#2Af2iDA8GbD0adUsHs8 zUMI!*0Q#KJNhJ*8%|tT>adfPRVbJdpoYXG{VGeh=lqyWjsACY1m`W4ppwD@nG;t2% zFz2K>P7sGNOm{UfbSPs6;(myR4C2tXH1P-GY(cvj#CaU;ZqV-$oOGE%`0l{repAus zoKCu@LBE4=(!C7gR-v2&ATIHhW(+`_?@{J?5VsFH*dYEl=um@j59Ops8uUANhx=5; z+FJJkoI8442O7kG9X-e(-k;Im8^k9z(-RGPf8nG#e?j~;DCaK-YiTEagh9X0a?)oS z%-E&R#y;D|M*3XrbIH$GrT=IU_g|=v4cA!K#!mVYgNc*$rPz8+pN?K>(C40OIh-xGJzw;Lol486l3o{odIbqW2c`yVpsGchN<)F93asP;Q+ zO7|1=F@rfDxi5G0-kf!U!+p7;-wiq3n=7`gmVbeC&AZZI&e^o~@1IH6dbNLF!q&d! z9M^kzJ(tru9(rDHMBg=tp3B_FD`LuOn?B*#_d!23=<`n}z1E=L2|DS|4f?K#lh(fe z7e15dcLs6UOm8yi^Jgc+Hi*9;>KMc)elo5>e8xY+90B6*k8P#I5`(xWp~OFke=OSDApTTz2ZQ*>q2&hgiNQ>TLHtQ* zUxWCEqE!a*HSYj}_~)Y$gZO)+Lk!|yg>vqI_~X%?4f?#r;TeJ={$=P0gZO))qYe73 z#>p@Spx*IRi>X?3Q5QpJ@zk7@cw&I>T}(#9}$IA{07?n$3;K5gt{(0A3Gjl?{N z*ML?V#Q#0Iqe1+MDE$uN6U!TkRnYrrXCpBI5)49#ThQ-EoQ;PV#Je0l!XUwZ=#d5q zw9aD<5@_BF4dOBPY+PUvA!aw;V-TO?+en;&_(_yF0pZ!Bv+)Ik_;b@mi}lVnNQo4n1#SFzfS&9$ z0XB{9L z6Gf`1tN$?G0FUv8^cvo1p1|9K)Heh_LsB9`VV94YOsaVKkNQR|5E(`J(bIX+Z#F-W zAm8qB{`1+ZMfRL4vNvV-UMmt^BQkD<$iCzqKTKpl{O?b`1IRa_LFB-fMGnI6MEo4Q zPGl0k55wOPLxKI1C-d^)NRgx9=%~mP@=a+JIc5UUTVX>rB3A5Hj&#t7FpOVa{FpdR_a+a zRphQkB6lz0|E3gRA&a1^yqNA+i*Ik5cAw>Rv`0p2W{nDUoN$ z_v}!S6}0C$j_C#RuUsSY;%t6YF;C>>A4Fc+Eb=OKzD9c*7W1MSdEb~X@+SLNHH!RY zyvSdv>zyi*zfBW)7oV%C<9+t6K|h3#sq0h9uceLum?N@oqR2njiF`$y*3;gv@zK15 zx5QS8e1o5F8$|v!L*zTk{4ho2N78>DDH4y0d8;Ey8(V3U-npgm%~eO*Xxm17Z&Egk z{6d*6bs}4t;ha_Cc&twJ&bMH;IE4$v>9k0k&RkKtFza-ES)3wf{!p7Z#lyrYnITRo z<+|02(|skUHRXEthPC4KCcV$c;#BzJ^c^ftmv-qyP#TgUlrMxP5Tb$qFb1b?S`=YdS9Q(%41?t>? zI<$#%0BHv{i1T~=9ZY?PG>S86wK#|4>xgOMOeX)4>%=)KCC-#i;v9poW668mN^y=a z5$A*@;?()#oD>u1WXhhhMx0Zp@YBJG;{1U!(@8sncAQB&&Kk+j3Q0d_vN-2fiF4ix zab|ri&IQzaA?=wxN1Th6iF0v-ICJoSS&=xGlRvgdoGVs|a}{Og&SUXJxoZ~lvLEfb zu8bG@sP6{SZloPIE*Iyfwc^~2-&^MMU}q8+kD=n+LHE%e-{JSW)!ck87Uze#yu3FMsQ>4|;>6jX=mgZC#2;^oIIWaRO=gvazbyNB>hJKR z*V#A}us6}ZU$B4qSe(uH{I!A`XX@M<<)&n)xK3|g=JO#Xu3sfCt2Vb_5;XDxU%ftz zggM+OmhiG3{|}q4V__K&RM4Uo&?atZK9D~=1-|8m6fIuM%X;`PnJaE7T1uI2Qz0&H zck-4k7q=(rJwFz=*LYYdZf~}GFXkaE+J`oj&jQjbrtzR=p175?tun<8J8kK&EERVE zWvi)kU>grl7K$5TJA#i$oRU~XdJz79ze?PLtAI8iLcT*MLy8v#Nk5D- zhpiF!@VVk1!Eqc}BJNR>#65Z}tm8oz?K-AT++&q`XcqUlfiMlQr;z3cM`tABuPZ5Z|Yxr!N)v49YT}xMz{)?5X0O zLmOtYpKGjpF2`}+Zm^CUgcuLJ2EuGu!{To+%z-v>FF-HE|Amy{y6awC2DIZ6^3I{$ zrL^HP{Klx~O3GciQrxQ+i#wO}xy!`8ntazR7x!9x&08Sub&FVN){A=s_069t?u}!` zy{R|g>t@O?z+ON-w@}9|l(}`TxVM#Xb5H*=Ke>01eh2-0C+%8;y#Q2XW6#`-_I=+_j!E3FbU$^h)x9d zy*N|cmzMJr_!Z*5vP|4pr;GdAByk(4=XJK<94hWA+VU3Ze|cHlzf$h)S>nF)t+?-s zxT}Z3T5;ba&-^#9>7rUQ`+|#+stq7+PUI>j?aHg z1AMKc{(oYBN!?$Ow~>A8$^SKZzg{hFiw|+V@r6}9c$&fkc=mnwwz%I@?#Gn4KjGu& z3F5}d!&|#f!ju%o5=eM>AzCvmigjtogE-b;_b|TV|8cRxyvkAC*H30;tktP zyxJo1hSSy&=t%sGg3(d&cBky`Mv6Cfvv_+=7cW{O-Z=J+qYe9#f8V*{jbAL@elLr+ z|7>nDIj#wF#5)lCAnN%2Oz|dC@4=JBJ9Lzl!kHa~JBPK#hyd%kb^b}|i zZwkk94BN+2-*GUNGRF(7f0hr;;+;KLymJ=u;5iCxpIZmid0tGs^E-(* zs|=R$qT4`NF5U&S;X-`OCf{t@auIc2jPHx_aY+@BXAZ|SXEQIaEfMds$=rC)6EC)q z7uCpnCFxg@XKsUdSI-yknyJtx-nBo7H?L8=>+pH~I&Kkr13qt@3FuAux|w#|GE%%- z2g73VZhKk0g{0j+MZ7yEh<9g&c#Fo0cNb}Qw~2SpTJiqm!z%Iaog?0Tb#QQV#y*O38m+HlPd9`@2koGEN8|Lw%7y8B`z5uL%72>@$ zi!Uu>;{CNwytk)__YP@)n=anF_SR-CDzFL-u_swMSzMUrC zzexXAn|R-k74HYy|0Di?DiQBz%Ear$OO(Mf@siZnirxCLc&Q(_;omG?o4{i6HZB!! z(>(Ehp*_E_?^pb6p)FgNiZ7eRcitA?8!x_JBz`c3FO@6cTk#8<#qT_gFJwv}B|hsP zzi5*9p|#?Nr}Kbew)iEp#4lYbez*DJcgIKfHt~C`7QZKDdo2{d_bTysm??grB|Ipd zAbth=D(8vcZ>0EDlC-snJoTp*t_Fzk23LpN1m~9@%N-o z)-3+sFN+@?FaEd%;_rj+@r%UYzmxa}z=R_452Wnxmx({IS^Pt&_t0;}KWvitN6?}0i|G0VLPn{$F@k@E2Jw^Pw67f&M? zRpOt$l81oIU1WT{uQKMxkmiC)OmGG{A=)eO|$s(>czi~ez?Aq_&4C^hPe3i*NT5rnfNzT z*8+~^7W7u?yKTJq3n_a$Wp5|Xoz%IAvUd*?eOb=uUdCU%PW*c*dmp~)DR=)$@gKzZ zgXCR8TOXpnhi8fZ$U^a#t``5%HR3-uSNz8(ioc9<%TnS$If)n9R*1iRGO*2hz<+MA z_%Boe_KVZ_a%iFWFE0`QmD#+M#`bGV#eZX<_-`)eMYB3+6aVcZ@!uiso#omsqBmU}tn@Y#&N7Sc9Ui2qFq zUpV=&TKwc;8CkFP0!!C_%wG2@02rz9-lXj?MS?yk*XE!+B?0#b0rv+%RTVT!Fkmp$NieWPfPmq;+5?nP!g1HkVxO%Vz*N}EC^c}^f}8Mj^I{1WQ0Fc665Q5Xf`#nAo$Wg&NpL5AIH!WU@N+ly-!oK#`zj=; ze_4Y2@%O+I36@L%?1!lDk+Bjit&-r;Itdv z&n}c;1@?3BJY`-OFTqO6iEM3fVork;NJz@0Ttrna=#i0TnFwS{AP)$}Sw zsz+4!svR?E^qAV((PR48_UzS@d)!`}$ZV~*Xj z!3l-SoT{>L$Wd!cqgs1h!{3$^(tzOwA#HotF5z8=RaQ8j?{@87 z>p4Ap^)BlfE-EM}?Y2W%Z+pCq_YQq}6a{`zP}DV4+M`EFZTSu^{}rNrZPw&x>9KEyM#)X|bcL{~MY@w~HPBiQEtE%ef1p6d@yH20cS_YQSUgi#J`RgZ5>lfJmf~U zSFIhl1`d5@*HBSmq30EycYasTE9g`d>N?X7(m6%>J?9m9^k;65Q_t>s`)U!2b{fAQ zJBqaEzSr8mzjmM8zQX-?>y~gM@qKFo#oB~zCjje+f33!&qihx?c))(v_Chm zjM6ik`9(*ex^|Q!wqXlwJqxzqa%TR=sIJfio@jLJA=Cyvu@^Z&1FW)h*j^ua8+KoER5XpH7|D`%CWlhf(hUt<{|UBdK-t8107wGt1^@QgdA~`?Lpc9?-keMrywyWA_@o zqwDN=-Ltmtt~ZKO6p)J2~!-gZCOcWL~Zf-Mg2(+Izr&UcH=L zALv+&r!6D(n#Q=(2GauDmV!bvL+dQYIEO9R|^zs zIe!O}-4xS2)S>;jnSP8p56JEsMS3+fGlEykO#8oz-rQWm^?)%R^NLo$Xcfc?J9jB< zw0%@%d$h`KJ$a^cp61Y;TjC|f#gpvrNp^SsELfk9Decd_wM}ETzaE;NWpw}N9%XhP zGp2vR4mvMT$wNKP%Xj7x{d(^Z3i6%lcirKLd}l5y?$X)q?_AT{wauED@5z$lKArPD z*{Mf+Pj)RTUe0OIem>qW{blTSJ*nqIS&y9-n~Pip1pS1p%)jEZI*MUX4N9ghV1S*j+;MY_Zhl#@cZxk z^t8Eq_St6XdCUCqr8AyBu56E4vkog{TD^0<;j@SKhdl$B6>3JaT&W(RX?AS& ztkB*XVXoBnbJVcfS~j;`+--(@A!nE~aM0+g(Srs$PId2aShu3O6%O~Vc48$ZCFR|^ zIZn53<DHBy4FYb(~TsswakXwtil9m8R?d`Yxa~RJj}nP;t&FlB z8A5*y(3;E@vVZ$Ev44;Mm^#d!zE1Aqu%-UD+pIP^EYTY}ZkV~5|5L6#zU~EP6!iEw zHafef9vSm5M@dwA+E}M>%f|fSIZ-{lEe-#};km2>8i~Dn&in)Q+}A4rajV0rS7RN{ zj;jEBjT+5LRb$+q5&1O&g}2>$Ow!1*VOcSxh9T2ikghvb6qmHexlNn2-EB#GAo~Nl zSM(X?f!Avpq9yz;0V{ z6cpz7Fwf~+#EMI z6$3})+dQPIQ^@A!Qwv77TZYCoNqyRG+0K*kT4mNR{rfxGfyQaCPUM}Gzf@`2hV2%s zdM>lZ`O>Uc`^#X)SMLlxy%#ePG@4b#2)knCkVbQb_qua?JEpeRmz{g`=rnsUw*}0M zMFsu-(l5V+A3S@=f&~jEOmODwz2cU}I^EAX6Ph?NL=bAWI;Uz&>Iq&|>^PV9lU-#m zT99AY*_B<#IaK?&x)=59VpE5WKrh^WVjUID&t5U@fzr~7zNS0s%wK*V8?n3{bLuq` z>+4MdJy+egZ|Q(xTDqZqI&7Z?W7=kC>qfJ#s25hJPNcfFeMQU)*u+Nee$B4oJES-V z){Y%pyX&s{$r`P5moDRvI%@n-{`2<5L)GfirL*3SXpU%l^s2<@>_t~}MH^q9`Vnjm_-b$bsV(YMlZD*KKYzIUA- zd%bp1ekOHHlHHrjNg*e?r6Iqr)_yQ;(6tQDLjG;sI@}Fxe{axnE1DmMMjQE!xe2g7 zzuj?kY~aVX6YYy_?bvy{cI4-s-`t!0Tm9DIf2p~_Hmv?Pwc0uxIQBi->(r3cPVV@; zf|-IKwX?@|GmEA&7>)TJ|J{%wMVH#T7L;*$@4VMu$95c}DSPULO#@))-cDV96r6$8 zyV;h7!@Z{LzK7d!$lNg+O{Y$K|Mz0Zj^Ph<|fl#w3*iWci7tY;Am8B z|Lw0uTg6svKOJe{Z~H-v#q9Q$oHn$#L1Tvd$_`h`5!>BQZM$w(liuOZvdVhQ-%L5O z{WiyDo1=I3+n#LY8@^rrJN-Y*y$P5kM|Cb*k+o#)`__9^*K}2H(^E6O)M!RRHIimD zqqS{WLVyG<5E4j$8w5fEaaBf^k*>iE=wf4x<-&j^F1+uK7i<;_73LZ+e#T>CUu>SA z-Ppc(`+T4L-t$BDz0|z_kCRcE)!nm5$csi@nHd=wnQ`Kr6K9JP0EpOQ#5zaP5M5ii zyBW=lwwoxv*+AXtvZGYuO278ZK(TbbQW!F7>CY7S|Eao6=*45Ru)%NL0mux^D`2%` zbq@D!sTOz!aNuW_y!|`!Fl_pZgBH34;#G9a`+EkC8X#)}RTB?E{G9 z*sx$C)%q+9r=GI4R;if$_{WpQq9Y1C9L;2+4?B=9s-FvHpM5qP43`Wtsp40Y$*=bR z*7=xD2{BCIM}GpI_%Yue{ceRf(B{SQ)MMOWiTfREtwJHCZ08;MLaP5y=3G42KlK92dNi)XPY#PnTo~=MRN>dGYMFYoyVYS!oH>++0q*1cqTd1`WUYI*q*_so1B$9IZ( zxQFHl@}wFuZD3hkr5W4I5x6DOgket`0+}}E2xBJMkV(Q2#fHG9v(+=!mYxHnr_~oV zjs2YWyqsLq5cfofU@N^{= z?@Z&R*8w=UOD%fd6Og{*PEEX^%paN^DJyGs?vhRY-*l-+`|7kzfxWH+-}f6LzAv+< zwsgtdthLo;HC6{r!{+fv(0!kTO`cyUHf30wwe|_T)PSU-Ij|tt0C4gbU@a(x|1`JZ zFrW_a@?Wb2tEAM@b({gIH(H?Qy8Wuj091^`#6e4qjE$Dc$wVl5sC6_~9?1s-mP%;T zNGxGPm(S<&iHj;b>sKm#B33BI6L!cSh{Y1od_EcsUQ|&pPK@Ov(MUE4YK^AS6O((| z1rIrQRbAM=gFW4$&@!=F5sP0h0^XxPZ{@+yz`uiv(1^oA|@ zhApFS@LKDJv_t>A=Iv{m>T>&YW(2h*q~7prD$G+tf9FF%94Mi8DcVE14JOpj^0)sL zbrH&gyx;Fos8=1F2IU>rWq60?E}nN3OWWQBEW)-BV1d)lI}0#22j6gGxKs+?_y#?K zFj<|96ifCG{6M%^jGV;#66$gO_P?d}kP`ZTqUtnkv39>xunMZw{~I1UYzy?Mux;s| zsULwqiZ*N4-e-$FofwPx$gA6UX|<*9C!v!!yz5;QoZx;4zxTa{dFV22iFogOX_gWW z^7B8>TBsfSOc8S-eHYj|8;->@1yPbVes2J-Y522v3)7ZQJ4wI@x@}S zyf(0QJ`#@i|L%WDv;7_~Re_gcdCla~{ZGpWsZ1`VuEDwRL^4Lzffn*wyb)4mW1tST zF!?VpmmtCzWxy-w*g|ar2P$*}YpAx)@)2^L8b^;l1srY6SoA;G`)j=o`O*z~ZOplR zUsvwjUOjxcx<}62IB=e*lp={-rIJfTN|?TY#jq|?PJ~s&oW3Pz+$8%x@%9{6Bfa3-|3<6)&SDjlzV@zMhTCy6sg^KbZlcx$Kb;ly$V>B&^-=P z3BP2r94bedfOJk{3c4tof# z+wr9Ov_Bm7XZp-pj72942*~Us^hoGv;JleC4s4v2L zcpRFD%%e4Q9~A0%$yYJX7=1vD_BnP{WOKpc0;OUb50aHqv!%JIX0;(ZCMwzp+JBmw zWd)xSRlZm(9=RL+;pJ*v^*>Xztm3-<=_SzPuF*ofY#(|?xzx^86!zeP(; zO|7gP2}@Vxw_+7%Mn;sr3r`B_{2E33e6HENjDZK6u$iTLUvZj4n@v`=R#vWK!$($D zrl!=e?)M1V7#W%2Z8{;WB48ANZqc)5G>oqBy^MLsb8GBii+$2FAdJBx?tv;=u0O9? zXlguQJ-|+zEGVsrMKL3dP*tO;iU4KB8ExY*IxJmt!t9H1%n5Vtuy2HzmB<)zDU-08 z%_$&^n$U6=g(;jWRheq)s*@^}Qj-v05Sg)FsZ@j}2=inc>^|B~hT$(+H(?kbA59m= zjo3C`NRNJelTCdLj*FRPHcsJgdB##7=UWj_fp)9$WrBJ+US%t7jk0)6e@np&*N#Nm z>0Ay%%651@@GlEbCAvU^te5PTV&srW^nHV~32=JgXh#YTbiZG4euLorhJX|POAi$L zWLJQ#s`z}+-_C4V9n6z+z7^jQ-?hG%`d)+V`;P#w!jCeV1&i5?D7NhJ@d_?hQAz|{ z1y+})^??QTT4}YgN<_zbLm!Cex)!EKb6zYdvIY~1vnu+xVdk7EG!7C+GWO)u!ot*~ z{acPI-)3pQT}i1-N~Jc!Vd7~xyv{=^vq=c>Z&MB_)jz$rTr8IN%CDCjK)FCuXM9Ay z`B8exN9P60pAmdaJrs$5Fa@ppjcoCaD)-Ps5Ap1S@yKH;)#E9C(lGuwrz+JysT#Ah zbnWuj2~m(neKzdPU$EY(egoO;|FiEY-8+?Trdo8bCut(Dca{VS_0U~`CN!G8zf@^50ATUj0c z5Ps4$X!1hA6dH6NfM_+*vR3M9C0jdboxlPylBoUZP>}w%U>G4g!I45Rno5L2`E)cH ziy&~siiX1Ja3C2|i2Q*%RhUSn{GoI@RtWf0cvM;CoHjYyNy|=$gQ;+M+zO>rek&Bp zVq;VoBO@`DO8f0}I-bWfUu-s;;aDP__M&9lVC_S?(HG)-!YO=@9ZE$agYU^C2j64* z@I8J?vm=-)zv^i}C<*FfA_bVlqVaGd98ED8A(MAHnOga!q0wzi&qkcj~{|6<7 z-3}CD>2wG!L}9fGA%tvRwcjhT*++V^D7@zR{ zs_$t~utP7;frdlQT%X60^vl0#SSm=tpTC<-+xSRJ#IxlA-XmQQBW2x9a5laXj48p^?x0K0<~ zvd0@Ll@ElAiA*IBfYuzCnSj|ciG7OwkTVKVJ0DBdtZ*?O2t?!gR7~H?xzF@Jy|R*5 zrE-}eIsENVh~l662tFmBQgocK+3PL<^~`fvFv`F0hO2ni4-c>B^5`{#DqJ zUHLRB_dh%Q74kj5i(S&UeFwh?V(+fyJ{vmP=d23!@T_kJKEX|2$M>Inf5TDUp^BDv z77c~e`o*1CT0t0-#tW)lqIzMpLa-kjF@`A4e_m724C1-L^}$G_7UR1^_Od1i9ZgO; zsx!!njTy2*^*s3sUPQj4iR?V*=H=u<;5-f070}D?hWDc19b(Lx%;f0k(#477QY2G| zUuQ=n!52lc`NS0q4w5XE%jE)M_tgZ8_l+U!6ucV-r))mnX8hR24RH3cOh#FaEiFyXgstoI ziAeTckzgo#cOf3hzUAuu(!nd|=jZLbvebcmJeqCqL!d!;mUT;fGA zree(#Sw~nv!v;dhyrQRgR5wS2cmo3(xl}xDt@HO?R^nZy(QD(eaIEl)^W@@W|JMWq z$IIi@v6qbgU^X{VuKkAwOJ8?O3X?&^pW#7S{U6W9;_CD9bS~9z^7pPx;_cbO#db6i zO@EXRbl9G?FI6~ExXezMU-d}@_Os}QBnUSWOYwj~pRp2&9N-Wdz@dc*@K<8LxCeX> zpBEmqcI^rUurpw`F_F?Fx*D7b&d^L~z+sLvqJSWvO-K+0u-JFpq-~YfP7+cadAuV@mK!& z+u#27&kRHK4%1j~Ys;y&r5PvRDf+w2O~+wfx*hxP-RpZ0+WY(9W7Ko6wl#?vwTP+2 ztUw_HC^$0{&8Cfh&0ugb;MOuANjN~u5RyddZc1?qWSKMbxo;0s0o09uX zi7%+}{bA7kLC`(d0l_jg!&#U>$KL_HoTWw&F5K^MG@?w5p``9@1A|3V!NK6r9DKH+ z2^wHG%t^~YUG;!iiQqye2n$3u7>Gtwx$m7y0}jgOuGL??2)2UoKKD#&s@z5QI_CKo#^}zHLG=Krj2jaAt zRWYgqsFIJ0ov#9!D~||@mvUdp)=CSYfTZZfO)>^8I%_MH2Ba3V>BJ|FF9VE@*gbRx zeAm8|HJAd{V9D4q(N`k>XQ<;~KTI-wEcz%9 z4=OvIDZEdm61k=zuKaFQr#_O*bPcv$`3t}B3x{eo1Ey#~6(d>%*L=gDk0-ha=N?jf zjSR||%{A9Yo$hl-tZ8y4Ra9tZ+?l~~rN-3*Q$iV{<$wVR}gx82ZjmL6@f<-RJ`|;S>?hnvo z)^NmK2wcY#R0Qmrw7cdtvG|3(X8UJu7!qISC-cwIhv>IyA@2Rfpz}4&Wob{7_$8y$ zK-#a>ZA2pLSQxGoBCxBR^$~2gjH!x40HA_u!9d-RVo?^X3cWHaz6v<9Q+GvUu~`2M zLLZYA!dJD&v)Rh<%BxKp_1oI`PJ^W5$=P?mz+~p-wu)?FMHw+c31eQdSp2c=K1)-YZ^QFS;W5f5uMVift{ z>bJB@Cwg)+Yn(V;vT1Sb{8+EoM6b8diweFAvqe+eWmT%8-8L|07wY`rHchm+E#8~{ zA0q_!q@y(e71$u}0xpzM26|oA_X=Ov_t(CE@cpw#x1$6mx?M?RZkk%wVD;o6@?Rdz zh`pc!R&Nv$%mbcI&($idm|#C^%dF2HPU9ccJq-IwLx@ktU5JXrrNMye#03%`{^^(S zio}V3d=-h-rdFT8&i{+ajqVoC`af~!#ntHybhi6zB`vnTO;958vaAP z7yc9UrnB?IlA!>@+ieRP$qMDuxU}_n9NC=Tw1aSmd zH2eUx78)O@kC3AIB&%tqRujCgPD-GPi$;dR_Ury)sj|3G3Iy8ebdzANu&)TYu>$HB zy!;1-B<@ zLZP%r-<$DW3EojGaSaeg0V11*;y~CVV955M^+9t9Qhx`N1g!LuZwMoG@JPMn6(kJ< z%+!?MKQ%RR;tLenL~#4ctUr4&7kTxY`?Kmu|7msPvPkY=*1!MGgx11eeZj3f*n8esY4-^u&g({!Lp(-XEKJPiuKuD$QLtM zkw?wd8E1*Zp#4c~PmI5ye_5tD^pgRd=nMv2YqOdaVooUO=LrgtU5Rt-qvrL%o?@xf zx!4X&(|sI?OqZ)PGLiknMzUEeXXUaJpPR_yXl1h_Kl!Q(NgOWwqUE0HQ2k19T2=>4 zm+&y!=%{phbZa8Vg_F67t{rK3+A1$Z^6r` z?F^hLWNs<)TQU1!XhGxrVwCKddB9sR;#y)k=gbBEBU6eyvm}5ep{Fv4Cd+2`E!{La zdehQAbTJmqJe4?~=yyI=I}i>fmvGh=axyk>Ekro_i81R9p3T9l zUiFHSEmZ;D3-;sJgdIbtNS9$ZhwvG$e5Z0A_karH2F~_%i&;WIKz7A>L7_@{| zO^|c1%$n@DvYm_w^3Jj?rFd7l*YA;CnrBE~5+g*tCGH!6CU>jOkU>n-&;Xv5ycaEZ zg}s3rgcu+rel@?uW2g^CDq^SjARLj}F{B};h&+inEcP`*zINJcBi!^(02@QVn;6Sv z35=fU#m(?WnRC@PXJ>pSR;p)CDwvQwF08J1QlsEHcujX&l$Xz7tG!`u!-7YHBA77Gs!Z;Hbm7N}dp* zfX|4*QLK_7yD=>@JZ2^lNEv6xxl1H?a&(Hwd<~KMl=L%w&HnwCb@=ejHy=K1sR|H= zY`(B?kt2$4Y!+!SOs0zF7^h{Ge{YWXm7UE5RAPqrl8uP9n6<2IJ8Kmh0Tt7d9o{mX zJa}-O&GR+w{biX)inS=F1Jc6(&ek_f^ZH~3LHm4({)tCaCSYf+X$uugRzAHLOJb_Q zo6?o?LNI8>5p9HdabU;X=etDfKXu69#-ht9tpJOqIhyr4R0pbhUTkn(9?ia51YZv7 zReO9$R6%0^ZtV9Oa$D_AdqEvbM`NLDlB4}Mk0!%4>&6>ze03@mi>B2Wf=sllZi~W7 z+iPW9_}T|=U$Lf9D;9YZXTbRQ=dUkDVrZ(@?XDZ)=E(A%p}wsmdJl<{hOE^Lz;N`3 z2K&h2=N|+CP%nCj=vg8MOk5BS8d?Hizz_5dIwoI22CzKb#Ji4xR{wGReCFnM}2r$%VtA5b{1goSp9fWqtG_ zb-e!vYO((Z%=%&@h&r16ADL7Nd0~EWz$bvk-VWasUR}M`1h-$kx>R30u(I5+ntS^{ z`uBhT_l43mtE-jCaX5wH#>|c$`J&-V=7?AY(_w^wLuvz8@SdOV)(mf;J@%oIV{YZT$(S#;4?M?`~=f$j?du)MvH|~ zgP5c~Uj-l!~1G3@#oP5Ik0u~(f%2w`e#zbT}ICH zEM}&>7uoMGL1I1&BxO*|C|Ulie-Rp968x(5SHJz+zn$3qu>mfWQz^@l)jE(ZHW20U z0AiVbA8@CH030Wd3Z?@c&E!v3C|WL{3)qrcC=dXrU9kZeY!p9d<`9a&80uJ1qbor7 zFjQw`Ke#s_qI@5U2tIyit36;D*tCvNie{sc@dVK>F&@E%4g-S26$RUBjU`ztIkv4< z)0kywpOir$-)`@2%)W3d^iXTPU0%HH^Dev-TgFI6ZF}&V_2*`d->I@~dRr44IdbCl z;0wFY_hax@z)?d1f~;~__JL6eF;Wyaj9Ns;4Woa6Uib%O!apftUKo~9xNs!VL2CzE zYz4AtiEcNcc%V+O1w*G`E;#M)A`)nH-@egfoQcJ$9mc{)oCnoJ3L4|c2z12MgbI>U zom-t$JQj$(8=)*wOf>qZ|1Dg_V$rDn`Nk7ZJdw7n^f&DAlSan+Vk&+-p2}PmVu3$x z+aZN@d3IXAICNDu$=hGlZ^;&)OvI<}^R|d{_hsOR0nSkxo_7PDKkfT3piFdApqVex znMEkMvN)j$as!*xu^zBSgNX5uWpsKSV7a8Q0)cC2l9U~bwzoBoz)-ft28kYT)=UA< zXmJqiK@&Y00c}4V<7L>tN&Fnwko{8tJ-`@G>9~R2p{@d6uxrR2<8ZaS$xkZbFeIgL zID&x92p|gBMi8hQ2|ojO09=e#F{~BUw_#MKxE^%qdCpaM~`+G>M&w`ROrnn(tsmysM~ZenbRNF7?vU@RPLh)8NA zl+_&&*e9s$y!KI1dAj=Iu^U`9)~R^Ie}>2CKZ@$r#pz=^zDa3GU56)PHU�Nl|_$ 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

E7E^ziYWPY-|AaZEuP^7qG)} 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ⅅ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 literal 0 HcmV?d00001 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index c65c0a0d..c7763f2d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -11,9 +11,16 @@ allprojects { val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() rootProject.layout.buildDirectory.value(newBuildDir) +val rootDrive = rootProject.projectDir.toPath().root?.toString()?.uppercase() + subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) + val projectDrive = project.projectDir.toPath().root?.toString()?.uppercase() + // Avoid Windows cross-drive relativize errors for plugins located in Pub cache (e.g. C:) when + // the workspace is on another drive (e.g. E:). + if (projectDrive == rootDrive) { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) + } } subprojects { project.evaluationDependsOn(":app") diff --git a/android/gradle.properties b/android/gradle.properties index ce0ad6e0..8d05787a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,8 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.java.home=C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot org.gradle.caching=true org.gradle.parallel=true org.gradle.daemon=true android.useAndroidX=true android.enableJetifier=true +android.suppressUnsupportedCompileSdk=35 diff --git a/lib/main.dart b/lib/main.dart index 73954b0a..803c3a59 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,6 +74,8 @@ class _MyAppState extends State { ), useMaterial3: true, ), + builder: (context, child) => + HeroControllerScope.none(child: child ?? const SizedBox.shrink()), themeMode: _themeMode, home: const MainPage(), ); diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart index 9758b9b5..2933c2cd 100644 --- a/lib/pages/ai_config_page.dart +++ b/lib/pages/ai_config_page.dart @@ -293,10 +293,11 @@ class _AiConfigPageState extends State { backgroundColor: cs.surface, body: CustomScrollView( slivers: [ - SliverAppBar.large( + SliverAppBar( title: Text(l10n.aiConfigTitle), backgroundColor: cs.surface, centerTitle: false, + pinned: true, ), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/pages/app_channels_page.dart b/lib/pages/app_channels_page.dart index 51ba8122..7a7cb3c5 100644 --- a/lib/pages/app_channels_page.dart +++ b/lib/pages/app_channels_page.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../controllers/whitelist_controller.dart'; @@ -304,15 +302,14 @@ class _AppChannelsPageState extends State { final l10n = AppLocalizations.of(context)!; final channels = _channels ?? []; final allEnabled = _appEnabled && _enabledChannels.isEmpty; - final appIconSizePx = (32 * MediaQuery.devicePixelRatioOf(context)).round(); - return Scaffold( backgroundColor: cs.surface, body: CustomScrollView( slivers: [ - SliverAppBar.large( + SliverAppBar( backgroundColor: cs.surface, centerTitle: false, + pinned: true, title: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -454,53 +451,58 @@ class _AppChannelsPageState extends State { ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final ch = channels[index]; - final isFirst = index == 0; - final isLast = index == channels.length - 1; - final channelEnabled = _isEnabled(ch.id); - final template = - _channelTemplates[ch.id] ?? kTemplateNotificationIsland; - final extras = _channelExtras[ch.id] ?? {}; - - return _ChannelTile( - channel: ch, - channelEnabled: channelEnabled, - appEnabled: _appEnabled, - template: template, - templateLabels: _templateLabels, - renderer: - extras['renderer'] ?? kRendererImageTextWithButtons4, - rendererLabels: _rendererLabels, - importanceLabel: _importanceLabel(ch.importance, l10n), - isFirst: isFirst, - isLast: isLast, - iconMode: extras['icon'] ?? kIconModeAuto, - focusIconMode: extras['focus_icon'] ?? kIconModeAuto, - focusNotif: extras['focus'] ?? kTriOptDefault, - preserveSmallIcon: - extras['preserve_small_icon'] ?? kTriOptDefault, - showIslandIcon: - extras['show_island_icon'] ?? kTriOptDefault, - firstFloat: extras['first_float'] ?? kTriOptDefault, - enableFloat: extras['enable_float'] ?? kTriOptDefault, - islandTimeout: extras['timeout'] ?? '5', - marquee: extras['marquee'] ?? kTriOptDefault, - restoreLockscreen: - extras['restore_lockscreen'] ?? kTriOptDefault, - highlightColor: extras['highlight_color'] ?? '', - showLeftHighlight: - extras['show_left_highlight'] ?? kTriOptOff, - showRightHighlight: - extras['show_right_highlight'] ?? kTriOptOff, - onToggle: (v) => _toggle(ch.id, v), - onSettingsApplied: (s) => _applyChannelSettings(ch.id, s), - ); - }, - childCount: channels.length, - addAutomaticKeepAlives: false, + sliver: SliverToBoxAdapter( + child: Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(channels.length, (index) { + final ch = channels[index]; + final isLast = index == channels.length - 1; + final channelEnabled = _isEnabled(ch.id); + final template = + _channelTemplates[ch.id] ?? + kTemplateNotificationIsland; + final extras = _channelExtras[ch.id] ?? {}; + + return _ChannelTile( + channel: ch, + channelEnabled: channelEnabled, + appEnabled: _appEnabled, + template: template, + templateLabels: _templateLabels, + renderer: + extras['renderer'] ?? + kRendererImageTextWithButtons4, + rendererLabels: _rendererLabels, + importanceLabel: _importanceLabel(ch.importance, l10n), + isLast: isLast, + iconMode: extras['icon'] ?? kIconModeAuto, + focusIconMode: extras['focus_icon'] ?? kIconModeAuto, + focusNotif: extras['focus'] ?? kTriOptDefault, + preserveSmallIcon: + extras['preserve_small_icon'] ?? kTriOptDefault, + showIslandIcon: + extras['show_island_icon'] ?? kTriOptDefault, + firstFloat: extras['first_float'] ?? kTriOptDefault, + enableFloat: extras['enable_float'] ?? kTriOptDefault, + islandTimeout: extras['timeout'] ?? '5', + marquee: extras['marquee'] ?? kTriOptDefault, + restoreLockscreen: + extras['restore_lockscreen'] ?? kTriOptDefault, + highlightColor: extras['highlight_color'] ?? '', + showLeftHighlight: + extras['show_left_highlight'] ?? kTriOptOff, + showRightHighlight: + extras['show_right_highlight'] ?? kTriOptOff, + onToggle: (v) => _toggle(ch.id, v), + onSettingsApplied: (s) => + _applyChannelSettings(ch.id, s), + ); + }), + ), ), ), ), @@ -591,7 +593,6 @@ class _ChannelTile extends StatelessWidget { required this.renderer, required this.rendererLabels, required this.importanceLabel, - required this.isFirst, required this.isLast, required this.iconMode, required this.focusIconMode, @@ -618,7 +619,6 @@ class _ChannelTile extends StatelessWidget { final String renderer; final Map rendererLabels; final String importanceLabel; - final bool isFirst; final bool isLast; final String iconMode; final String focusIconMode; @@ -667,86 +667,75 @@ class _ChannelTile extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - final radius = BorderRadius.vertical( - top: isFirst ? const Radius.circular(16) : Radius.zero, - bottom: isLast ? const Radius.circular(16) : Radius.zero, - ); return Column( mainAxisSize: MainAxisSize.min, children: [ - Material( - color: cs.surfaceContainerHighest, - borderRadius: radius, - child: InkWell( - borderRadius: radius, - onTap: appEnabled ? () => onToggle(!channelEnabled) : null, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 4, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - channel.name, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith( - color: appEnabled - ? null - : cs.onSurface.withValues(alpha: 0.38), - ), + InkWell( + onTap: appEnabled ? () => onToggle(!channelEnabled) : null, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 4, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channel.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: appEnabled + ? null + : cs.onSurface.withValues(alpha: 0.38), ), - if (channel.description.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - channel.description, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: appEnabled - ? cs.onSurfaceVariant - : cs.onSurface.withValues(alpha: 0.28), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ), + if (channel.description.isNotEmpty) ...[ const SizedBox(height: 2), Text( - l10n.channelImportance(importanceLabel, channel.id), + channel.description, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: appEnabled - ? cs.onSurfaceVariant.withValues(alpha: 0.7) - : cs.onSurface.withValues(alpha: 0.22), + ? cs.onSurfaceVariant + : cs.onSurface.withValues(alpha: 0.28), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], - ), - ), - IconButton( - icon: Icon( - Icons.settings_outlined, - size: 22, - color: appEnabled && channelEnabled - ? cs.onSurfaceVariant - : cs.onSurface.withValues(alpha: 0.28), - ), - onPressed: appEnabled && channelEnabled - ? () => _openSettings(context) - : null, - tooltip: l10n.channelSettings, + const SizedBox(height: 2), + Text( + l10n.channelImportance(importanceLabel, channel.id), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: appEnabled + ? cs.onSurfaceVariant.withValues(alpha: 0.7) + : cs.onSurface.withValues(alpha: 0.22), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - Switch( - value: channelEnabled, - onChanged: appEnabled ? onToggle : null, + ), + IconButton( + icon: Icon( + Icons.settings_outlined, + size: 22, + color: appEnabled && channelEnabled + ? cs.onSurfaceVariant + : cs.onSurface.withValues(alpha: 0.28), ), - ], - ), + onPressed: appEnabled && channelEnabled + ? () => _openSettings(context) + : null, + tooltip: l10n.channelSettings, + ), + Switch( + value: channelEnabled, + onChanged: appEnabled ? onToggle : null, + ), + ], ), ), ), diff --git a/lib/pages/blacklist_page.dart b/lib/pages/blacklist_page.dart index aea9611c..ca5f6915 100644 --- a/lib/pages/blacklist_page.dart +++ b/lib/pages/blacklist_page.dart @@ -83,9 +83,10 @@ class _BlacklistPageState extends State { child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ - SliverAppBar.large( + SliverAppBar( backgroundColor: cs.surface, centerTitle: false, + pinned: true, title: Text(l10n.navBlacklist), actions: [ IconButton( diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 380bc11f..f6584449 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -9,6 +9,7 @@ import '../l10n/generated/app_localizations.dart'; import '../services/interaction_haptics.dart'; import '../widgets/section_label.dart'; import '../widgets/modern_slider.dart'; +import '../routes/card_push_route.dart'; import 'ai_config_page.dart'; import 'blacklist_page.dart'; @@ -364,7 +365,7 @@ class _SettingsPageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.push( context, - MaterialPageRoute( + buildCardPushRoute( builder: (context) => const AiConfigPage(), ), ), @@ -399,7 +400,7 @@ class _SettingsPageState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute( + buildCardPushRoute( builder: (context) => const BlacklistPage(), ), ); diff --git a/lib/pages/whitelist_page.dart b/lib/pages/whitelist_page.dart index 8fcfca9f..085371d7 100644 --- a/lib/pages/whitelist_page.dart +++ b/lib/pages/whitelist_page.dart @@ -5,6 +5,7 @@ import '../controllers/whitelist_controller.dart'; import '../l10n/generated/app_localizations.dart'; import '../widgets/batch_channel_settings_sheet.dart'; import '../widgets/app_list_widgets.dart'; +import '../routes/card_push_route.dart'; import 'app_channels_page.dart'; import '../services/app_cache_service.dart'; @@ -437,7 +438,7 @@ class WhitelistPageState extends State { ? () => _toggleSelection(pkg) : () => Navigator.push( context, - MaterialPageRoute( + buildCardPushRoute( builder: (_) => AppChannelsPage( app: app, controller: _ctrl, diff --git a/lib/routes/card_push_route.dart b/lib/routes/card_push_route.dart new file mode 100644 index 00000000..6ee8dd0d --- /dev/null +++ b/lib/routes/card_push_route.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; + +Route buildCardPushRoute({ + required WidgetBuilder builder, + RouteSettings? settings, +}) { + return PageRouteBuilder( + settings: settings, + transitionDuration: const Duration(milliseconds: 320), + reverseTransitionDuration: const Duration(milliseconds: 240), + pageBuilder: (context, animation, secondaryAnimation) { + return HeroMode( + enabled: false, + child: builder(context), + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + final slide = Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(curved); + + return AnimatedBuilder( + animation: curved, + child: child, + builder: (context, child) { + final t = curved.value; + final cardProgress = 1 - t; + final radius = lerpDouble(24, 0, t) ?? 0; + final horizontalInset = lerpDouble(12, 0, t) ?? 0; + final bottomInset = lerpDouble(12, 0, t) ?? 0; + final shadowBlur = lerpDouble(26, 0, t) ?? 0; + + return SlideTransition( + position: slide, + child: Padding( + padding: EdgeInsets.fromLTRB( + horizontalInset, + 0, + horizontalInset, + bottomInset, + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + boxShadow: shadowBlur <= 0 + ? const [] + : [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.20 * cardProgress, + ), + blurRadius: shadowBlur, + offset: const Offset(-8, 12), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: child, + ), + ), + ), + ); + }, + ); + }, + ); +} diff --git a/lib/widgets/batch_channel_settings_sheet.dart b/lib/widgets/batch_channel_settings_sheet.dart index 5e7fa54d..9957d18c 100644 --- a/lib/widgets/batch_channel_settings_sheet.dart +++ b/lib/widgets/batch_channel_settings_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../controllers/settings_controller.dart'; import '../controllers/whitelist_controller.dart'; import '../l10n/generated/app_localizations.dart'; +import 'section_label.dart'; // ── 操作模式(sealed class)────────────────────────────────────────────────── @@ -120,6 +121,7 @@ class BatchChannelSettingsSheet extends StatefulWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + enableDrag: false, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -288,12 +290,24 @@ class _BatchChannelSettingsSheetState extends State { ], ), actions: [ - TextButton( + OutlinedButton( onPressed: () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom( + minimumSize: const Size(88, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, selectedColor.toColor()), + style: FilledButton.styleFrom( + minimumSize: const Size(88, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), child: Text(l10n.apply), ), ], @@ -381,9 +395,9 @@ class _BatchChannelSettingsSheetState extends State { final titleBottomPadding = 12.0; final contentTopPadding = 12.0; final contentBottomPadding = 4.0; - final sectionTitleGap = 6.0; + final sectionTitleGap = 8.0; final rowGap = 10.0; - final blockGap = 16.0; + final blockGap = 8.0; final scopeGap = 12.0; final endGap = 20.0; @@ -459,7 +473,7 @@ class _BatchChannelSettingsSheetState extends State { ], // ── 模板 & 样式设置 ──────────────────────────────────── - _SectionLabel(l10n.template), + SectionLabel(l10n.template), SizedBox(height: sectionTitleGap), _BatchSettingRow( label: l10n.template, @@ -493,7 +507,7 @@ class _BatchChannelSettingsSheetState extends State { SizedBox(height: blockGap), // ── 超级岛 ───────────────────────────────────────────── - _SectionLabel(l10n.islandSection), + SectionLabel(l10n.islandSection), SizedBox(height: sectionTitleGap), _BatchSettingRow( label: l10n.islandIcon, @@ -747,7 +761,7 @@ class _BatchChannelSettingsSheetState extends State { SizedBox(height: blockGap), // ── 焦点通知 ─────────────────────────────────────────── - _SectionLabel(l10n.focusNotificationLabel), + SectionLabel(l10n.focusNotificationLabel), SizedBox(height: sectionTitleGap), _BatchSettingRow( label: l10n.focusIconLabel, @@ -922,28 +936,6 @@ class _KeyboardInsetPadding extends StatelessWidget { } } -// ── 分组标题 ────────────────────────────────────────────────────────────────── - -class _SectionLabel extends StatelessWidget { - const _SectionLabel(this.label); - final String label; - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Align( - alignment: Alignment.centerLeft, - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: cs.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - // ── 应用范围切换卡片 ────────────────────────────────────────────────────────── class _ScopeToggleCard extends StatelessWidget { From 910ef47416fa90db127e26bbb0e7ec2f439b58ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 00:27:32 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E9=80=82=E9=85=8D1.9.11;=20=E6=9C=AA?= =?UTF-8?q?=E9=80=82=E9=85=8D=E6=B7=B1=E8=89=B2=E5=92=8C=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + TopAppBar_ref.kt | 1100 +++++++++++++++++ android/app/build.gradle.kts | 1 + .../github/hyperisland/data/prefs/PrefKeys.kt | 2 + .../data/prefs/SettingsRepository.kt | 2 + .../hyperisland/data/prefs/SettingsState.kt | 2 + .../hyperisland/ui/ComposeMainActivity.kt | 324 ++++- .../ui/app/AppAdaptationRepository.kt | 24 + .../hyperisland/ui/app/AppChannelsUiState.kt | 4 + .../ui/app/AppChannelsViewModel.kt | 4 + .../github/hyperisland/ui/app/AppsScreens.kt | 405 ++++-- .../ui/settings/SettingsViewModel.kt | 2 + 12 files changed, 1698 insertions(+), 173 deletions(-) create mode 100644 TopAppBar_ref.kt diff --git a/.gitignore b/.gitignore index b694d3ab..75d23930 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ opencode.json /node_modules /docs/.vitepress/dist tmp_* +*.zip \ No newline at end of file diff --git a/TopAppBar_ref.kt b/TopAppBar_ref.kt new file mode 100644 index 00000000..8871b48f --- /dev/null +++ b/TopAppBar_ref.kt @@ -0,0 +1,1100 @@ +// Copyright 2025, compose-miuix-ui contributors +// SPDX-License-Identifier: Apache-2.0 + +package top.yukonga.miuix.kmp.basic + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirst +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.anim.folmeSpring +import top.yukonga.miuix.kmp.basic.TopAppBarState.Companion.Saver +import top.yukonga.miuix.kmp.theme.MiuixTheme +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A [TopAppBar] with Miuix style that can collapse and expand based on the + * scroll position of the content below it. + * + * The [TopAppBar] can be configured with a title, a navigation icon, and action icons. + * The large title will collapse when the content is scrolled up and expand when + * the content is scrolled down. + * + * @param title The title of the [TopAppBar]. + * @param modifier The modifier to be applied to the [TopAppBar]. + * @param color The background color of the [TopAppBar]. + * @param titleColor The color of the collapsed small title text. + * @param largeTitle The large title of the [TopAppBar]. + * @param largeTitleColor The color of the expanded large title text. + * @param subtitle The subtitle displayed below the title bar area. + * @param subtitleColor The color of the subtitle text. + * @param navigationIcon The [Composable] content that represents the navigation icon. + * @param actions The [Composable] content that represents the action icons. + * @param scrollBehavior The [ScrollBehavior] that controls the behavior of the [TopAppBar]. + * @param defaultWindowInsetsPadding Whether to apply default window insets padding to the [TopAppBar]. + * @param titlePadding The horizontal padding of the [TopAppBar]'s title & large title. + * @param navigationIconPadding The start padding of the navigation icon. + * @param actionIconPadding The end padding of the action icons. + * @param bottomContent The [Composable] content displayed below the title bar area. + */ +@Composable +fun TopAppBar( + title: String, + modifier: Modifier = Modifier, + color: Color = MiuixTheme.colorScheme.surface, + titleColor: Color = MiuixTheme.colorScheme.onSurface, + largeTitle: String = title, + largeTitleColor: Color = MiuixTheme.colorScheme.onSurface, + subtitle: String = "", + subtitleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: ScrollBehavior? = null, + defaultWindowInsetsPadding: Boolean = true, + titlePadding: Dp = TopAppBarDefaults.TitlePadding, + navigationIconPadding: Dp = TopAppBarDefaults.NavigationIconPadding, + actionIconPadding: Dp = TopAppBarDefaults.ActionIconPadding, + bottomContent: @Composable () -> Unit = {}, +) { + val largeTitleHeight = remember { mutableIntStateOf(0) } + val expandedHeightPx by remember { + derivedStateOf { + largeTitleHeight.intValue.toFloat().coerceAtLeast(0f) + } + } + + SideEffect { + // Sets the app bar's height offset to collapse the entire bar's height when content is scrolled. + if (scrollBehavior?.state?.heightOffsetLimit != -expandedHeightPx) { + scrollBehavior?.state?.heightOffsetLimit = -expandedHeightPx + } + } + + // Wrap the given actions in a Row. + val actionsRow = + @Composable { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + + // Compose a Surface with a TopAppBarLayout content. + // The surface's background color is animated as specified above. + // The height of the app bar is determined by subtracting the bar's height offset from the + // app bar's defined constant height value (i.e. the ContainerHeight token). + TopAppBarLayout( + title = title, + color = color, + titleColor = titleColor, + largeTitleColor = largeTitleColor, + subtitle = subtitle, + subtitleColor = subtitleColor, + navigationIcon = navigationIcon, + actions = actionsRow, + titlePadding = titlePadding, + navigationIconPadding = navigationIconPadding, + actionIconPadding = actionIconPadding, + scrolledOffset = { scrollBehavior?.state?.heightOffset ?: 0f }, + expandedHeightPx = expandedHeightPx, + largeTitleHeight = largeTitleHeight, + modifier = modifier, + largeTitle = largeTitle, + defaultWindowInsetsPadding = defaultWindowInsetsPadding, + bottomContent = bottomContent, + ) +} + +/** + * A [SmallTopAppBar] with Miuix style. + * + * The [SmallTopAppBar] can be configured with a title, a navigation icon, and action icons. + * + * @param title The title of the [SmallTopAppBar]. + * @param modifier The modifier to be applied to the [SmallTopAppBar]. + * @param color The background color of the [SmallTopAppBar]. + * @param titleColor The color of the title text. + * @param subtitle The subtitle displayed below the title bar area. + * @param subtitleColor The color of the subtitle text. + * @param navigationIcon The [Composable] content that represents the navigation icon. + * @param actions The [Composable] content that represents the action icons. + * @param scrollBehavior The [ScrollBehavior] that controls the behavior of the [SmallTopAppBar]. + * @param defaultWindowInsetsPadding Whether to apply default window insets padding to the [SmallTopAppBar]. + * @param titlePadding The horizontal padding of the [SmallTopAppBar]'s title. + * @param navigationIconPadding The start padding of the navigation icon. + * @param actionIconPadding The end padding of the action icons. + * @param bottomContent The [Composable] content displayed below the title bar area. + */ +@Composable +@NonRestartableComposable +fun SmallTopAppBar( + title: String, + modifier: Modifier = Modifier, + color: Color = MiuixTheme.colorScheme.surface, + titleColor: Color = MiuixTheme.colorScheme.onSurface, + subtitle: String = "", + subtitleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: ScrollBehavior? = null, + defaultWindowInsetsPadding: Boolean = true, + titlePadding: Dp = TopAppBarDefaults.TitlePadding, + navigationIconPadding: Dp = TopAppBarDefaults.NavigationIconPadding, + actionIconPadding: Dp = TopAppBarDefaults.ActionIconPadding, + bottomContent: @Composable () -> Unit = {}, +) { + SideEffect { + // Sets the height offset limit of the SmallTopAppBar to 0f + // To ensure that the content can still scroll normally even when scrollBehavior is passed. + scrollBehavior?.state?.heightOffsetLimit = 0f + } + + // Wrap the given actions in a Row. + val actionsRow = + @Composable { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + + // Compose a Surface with a SmallTopAppBarLayout content. + // The surface's background color is animated as specified above. + // The height of the app bar is determined by subtracting the bar's height offset from the + // app bar's defined constant height value (i.e. the ContainerHeight token). + SmallTopAppBarLayout( + title = title, + color = color, + titleColor = titleColor, + subtitle = subtitle, + subtitleColor = subtitleColor, + navigationIcon = navigationIcon, + actions = actionsRow, + titlePadding = titlePadding, + navigationIconPadding = navigationIconPadding, + actionIconPadding = actionIconPadding, + modifier = modifier, + defaultWindowInsetsPadding = defaultWindowInsetsPadding, + bottomContent = bottomContent, + ) +} + +/** + * Returns a [ScrollBehavior] that adjusts its properties to affect the colors and + * height of the top app bar. + * + * A top app bar that is set up with this [ScrollBehavior] will immediately collapse + * when the nested content is pulled up, and will expand back the collapsed area when the + * content is pulled all the way down. + * + * @param state the state object to be used to control or observe the top app bar's scroll + * state. See [rememberTopAppBarState] for a state that is remembered across compositions. + * @param canScroll a callback used to determine whether scroll events are to be handled by this + * [ExitUntilCollapsedScrollBehavior] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps + * to either fully collapsed or fully extended state when a fling or a drag scrolled it into + * an intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top + * app bar when the user flings the app bar itself, or the content below it + */ +@Suppress("ComposableNaming") +@Composable +fun MiuixScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = 2500f), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), +): ScrollBehavior = remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) { + ExitUntilCollapsedScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll, + ) +} + +/** + * Creates a [TopAppBarState] that is remembered across compositions. + * + * @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit], which + * represents the pixel limit that a top app bar is allowed to collapse when the scrollable + * content is scrolled + * @param initialHeightOffset the initial value for [TopAppBarState.heightOffset]. The initial + * offset height offset should be between zero and [initialHeightOffsetLimit]. + * @param initialContentOffset the initial value for [TopAppBarState.contentOffset] + */ +@Composable +fun rememberTopAppBarState( + initialHeightOffsetLimit: Float = -Float.MAX_VALUE, + initialHeightOffset: Float = 0f, + initialContentOffset: Float = 0f, +): TopAppBarState = rememberSaveable(saver = Saver) { + TopAppBarState(initialHeightOffsetLimit, initialHeightOffset, initialContentOffset) +} + +/** + * A state object that can be hoisted to control and observe the top app bar state. The state is + * read and updated by a [ScrollBehavior] implementation. + * + * In most cases, this state will be created via [rememberTopAppBarState]. + * + * @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit] + * @param initialHeightOffset the initial value for [TopAppBarState.heightOffset] + * @param initialContentOffset the initial value for [TopAppBarState.contentOffset] + */ +@Stable +class TopAppBarState( + initialHeightOffsetLimit: Float, + initialHeightOffset: Float, + initialContentOffset: Float, +) { + + /** + * The top app bar's height offset limit in pixels, which represents the limit that a top app + * bar is allowed to collapse to. + * + * Use this limit to coerce the [heightOffset] value when it's updated. + */ + var heightOffsetLimit = initialHeightOffsetLimit + + /** + * The top app bar's current height offset in pixels. This height offset is applied to the fixed + * height of the app bar to control the displayed height when content is being scrolled. + * + * Updates to the [heightOffset] value are coerced between zero and [heightOffsetLimit]. + */ + var heightOffset: Float + get() = _heightOffset.floatValue + set(newOffset) { + _heightOffset.floatValue = + newOffset.coerceIn(minimumValue = heightOffsetLimit, maximumValue = 0f) + } + + /** + * The total offset of the content scrolled under the top app bar. + * + * The content offset is used to compute the [overlappedFraction], which can later be read by an + * implementation. + * + * This value is updated by a [ScrollBehavior] whenever a nested scroll connection + * consumes scroll events. A common implementation would update the value to be the sum of all + * [NestedScrollConnection.onPostScroll] `consumed.y` values. + */ + var contentOffset by mutableFloatStateOf(initialContentOffset) + + /** + * A value that represents the collapsed height percentage of the app bar. + * + * A `0.0` represents a fully expanded bar, and `1.0` represents a fully collapsed bar (computed + * as [heightOffset] / [heightOffsetLimit]). + */ + val collapsedFraction: Float + get() = + if (heightOffsetLimit != 0f) { + heightOffset / heightOffsetLimit + } else { + 0f + } + + /** + * A value that represents the percentage of the app bar area that is overlapping with the + * content scrolled behind it. + * + * A `0.0` indicates that the app bar does not overlap any content, while `1.0` indicates that + * the entire visible app bar area overlaps the scrolled content. + */ + val overlappedFraction: Float + get() = + if (heightOffsetLimit != 0f) { + 1 - + ( + (heightOffsetLimit - contentOffset).coerceIn( + minimumValue = heightOffsetLimit, + maximumValue = 0f, + ) / heightOffsetLimit + ) + } else { + 0f + } + + companion object { + /** The default [Saver] implementation for [TopAppBarState]. */ + val Saver: Saver = + listSaver( + save = { listOf(it.heightOffsetLimit, it.heightOffset, it.contentOffset) }, + restore = { + TopAppBarState( + initialHeightOffsetLimit = it[0], + initialHeightOffset = it[1], + initialContentOffset = it[2], + ) + }, + ) + } + + private var _heightOffset = mutableFloatStateOf(initialHeightOffset) +} + +/** Contains default values used by [TopAppBar] and [SmallTopAppBar]. */ +object TopAppBarDefaults { + /** The default horizontal padding of the title and large title. */ + val TitlePadding = 26.dp + + /** The default start padding of the navigation icon. */ + val NavigationIconPadding = 16.dp + + /** The default end padding of the action icons. */ + val ActionIconPadding = 16.dp + + /** The default collapsed height of the [TopAppBar]. */ + val CollapsedHeight = 52.dp + + /** The vertical center height used for [SmallTopAppBar] layout. */ + val SmallTopAppBarCenterHeight = 50.dp + + /** The bottom padding below the large title when no subtitle is present. */ + val LargeTitleBottomPadding = 4.dp + + /** The bottom padding below the subtitle (both large and small). */ + val SubtitleBottomPadding = 8.dp +} + +@Stable +interface ScrollBehavior { + + /** + * A [TopAppBarState] that is attached to this behavior and is read and updated when scrolling + * happens. + */ + val state: TopAppBarState + + /** + * Indicates whether the top app bar is pinned. + * + * A pinned app bar will stay fixed in place when content is scrolled and will not react to any + * drag gestures. + */ + val isPinned: Boolean + + /** + * An optional [AnimationSpec] that defines how the top app bar snaps to either fully collapsed + * or fully extended state when a fling or a drag scrolled it into an intermediate position. + */ + val snapAnimationSpec: AnimationSpec? + + /** + * An optional [DecayAnimationSpec] that defined how to fling the top app bar when the user + * flings the app bar itself, or the content below it. + */ + val flingAnimationSpec: DecayAnimationSpec? + + /** + * A [NestedScrollConnection] that should be attached to a [Modifier.nestedScroll] in order to + * keep track of the scroll events. + */ + val nestedScrollConnection: NestedScrollConnection +} + +/** + * A [ScrollBehavior] that adjusts its properties to affect the colors and height of a top + * app bar. + * + * A top app bar that is set up with this [ScrollBehavior] will immediately collapse when + * the nested content is pulled up, and will expand back the collapsed area when the content is + * pulled all the way down. + * + * @param state a [TopAppBarState] + * @param snapAnimationSpec an optional [AnimationSpec] that defines how the top app bar snaps to + * either fully collapsed or fully extended state when a fling or a drag scrolled it into an + * intermediate position + * @param flingAnimationSpec an optional [DecayAnimationSpec] that defined how to fling the top app + * bar when the user flings the app bar itself, or the content below it + * @param canScroll a callback used to determine whether scroll events are to be handled by this + * [ExitUntilCollapsedScrollBehavior] + */ +private class ExitUntilCollapsedScrollBehavior( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true }, +) : ScrollBehavior { + override val isPinned: Boolean = false + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Don't intercept if scrolling down. + if (!canScroll() || available.y > 0) return Offset.Zero + val prevHeightOffset = state.heightOffset + state.heightOffset += available.y + return if (prevHeightOffset != state.heightOffset) { + // We're in the middle of top app bar collapse or expand. + // Consume only the scroll on the Y axis. + available.copy(x = 0f) + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + + if (available.y < 0f || consumed.y < 0f) { + // When scrolling up, just update the state's height offset. + val oldHeightOffset = state.heightOffset + state.heightOffset += consumed.y + return Offset(0f, state.heightOffset - oldHeightOffset) + } + + if (available.y > 0f) { + // Adjust the height offset in case the consumed delta Y is less than what was + // recorded as available delta Y in the pre-scroll. + val oldHeightOffset = state.heightOffset + state.heightOffset += available.y + return Offset(0f, state.heightOffset - oldHeightOffset) + } + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (available.y > 0) { + // Reset the total content offset to zero when scrolling all the way down. This + // will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + val superConsumed = super.onPostFling(consumed, available) + return superConsumed + + settleAppBar(state, available.y, flingAnimationSpec, snapAnimationSpec) + } + } +} + +/** + * Settles the app bar to a stable state (fully expanded or collapsed) by animating + * its height offset. + * + * This function is invoked after a drag or fling gesture, using the provided velocity + * to drive a decay animation, followed by a snap animation if the bar is left in an + * intermediate state. + * + * @param state The [TopAppBarState] that holds the current and target height offsets. + * @param velocity The velocity from the fling gesture to be consumed. + * @param flingAnimationSpec The [DecayAnimationSpec] for the fling animation. + * @param snapAnimationSpec The [AnimationSpec] for the final snap to a stable state. + * @return The [Velocity] that was actually consumed by the fling decay animation. This + * ensures accurate reporting within the nested scroll system, allowing any unconsumed + * velocity to be propagated to parent consumers. + */ +private suspend fun settleAppBar( + state: TopAppBarState, + velocity: Float, + flingAnimationSpec: DecayAnimationSpec?, + snapAnimationSpec: AnimationSpec?, +): Velocity { + // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, + // and just return Zero Velocity. + // Note that we don't check for 0f due to float precision with the collapsedFraction + // calculation. + if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) { + return Velocity.Zero + } + var remainingVelocity = velocity + // In case there is an initial velocity that was left after a previous user fling, animate to + // continue the motion to expand or collapse the app bar. + if (flingAnimationSpec != null && abs(velocity) > 1f) { + var lastValue = 0f + AnimationState(initialValue = 0f, initialVelocity = velocity).animateDecay( + flingAnimationSpec, + ) { + val delta = value - lastValue + val initialHeightOffset = state.heightOffset + state.heightOffset = initialHeightOffset + delta + val consumed = abs(initialHeightOffset - state.heightOffset) + lastValue = value + remainingVelocity = this.velocity + // avoid rounding errors and stop if anything is unconsumed + if (abs(delta - consumed) > 0.5f) this.cancelAnimation() + } + } + // Snap if animation specs were provided. + if (snapAnimationSpec != null) { + if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) { + AnimationState(initialValue = state.heightOffset).animateTo( + if (state.collapsedFraction < 0.5f) { + 0f + } else { + state.heightOffsetLimit + }, + animationSpec = snapAnimationSpec, + ) { + state.heightOffset = value + } + } + } + return Velocity(0f, velocity - remainingVelocity) +} + +/** A functional interface for providing an app-bar scroll offset. */ +private fun interface ScrolledOffset { + fun offset(): Float +} + +/** + * The base [Layout] for [TopAppBar]. This function lays out a [TopAppBar] navigation icon + * (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and + * the actions are optional. + * + * @param title the [TopAppBar] title (header). + * @param color the background color of the [TopAppBar]. + * @param titleColor the color of the collapsed small title text. + * @param largeTitleColor the color of the expanded large title text. + * @param subtitle the subtitle text displayed below the title bar area. + * @param subtitleColor the color of the subtitle text. + * @param navigationIcon a navigation icon [Composable]. + * @param actions actions [Composable]. + * @param titlePadding the horizontal padding of the [TopAppBar]'s title & large title. + * @param navigationIconPadding the start padding of the navigation icon. + * @param actionIconPadding the end padding of the action icons. + * @param scrolledOffset a function that provides the scroll offset of the [TopAppBar]. + * @param largeTitleHeight a mutable state that holds the height of the large title content (including subtitle). + * @param expandedHeightPx the expanded height of the [TopAppBar] in pixels. + * @param modifier the [Modifier] to be applied to this layout. + * @param largeTitle the large title of the [TopAppBar], if not specified, it will be the same as title. + * @param defaultWindowInsetsPadding whether to apply default window insets padding to the [TopAppBar]. + * @param bottomContent the composable content displayed below the title bar area. + */ +@Composable +private fun TopAppBarLayout( + title: String, + color: Color, + titleColor: Color, + largeTitleColor: Color, + subtitle: String, + subtitleColor: Color, + navigationIcon: @Composable () -> Unit, + actions: @Composable () -> Unit, + titlePadding: Dp, + navigationIconPadding: Dp, + actionIconPadding: Dp, + scrolledOffset: ScrolledOffset, + expandedHeightPx: Float, + largeTitleHeight: MutableState, + modifier: Modifier = Modifier, + largeTitle: String = title, + defaultWindowInsetsPadding: Boolean = true, + bottomContent: @Composable () -> Unit = {}, +) { + // Subtract the scrolledOffset from the maxHeight + val heightOffset by remember(scrolledOffset) { + derivedStateOf { + val offset = scrolledOffset.offset() + if (offset.isNaN()) 0 else offset.roundToInt() + } + } + + // Small Title Animation + val extOffset by remember(heightOffset) { + derivedStateOf { + abs(heightOffset) / expandedHeightPx * 3 + } + } + + // Large Title Alpha Animation + val largeTitleAlpha by remember(heightOffset, expandedHeightPx) { + derivedStateOf { + 1f - (abs(heightOffset) / expandedHeightPx * 3).coerceIn(0f, 1f) + } + } + + // Small title animation is triggered once when the threshold is crossed + // then runs independently to completion + val smallTitleVisible = extOffset >= 1f + val smallTitleAlpha = remember { Animatable(0f) } + val smallTitleTranslationY = remember { Animatable(20f) } + + LaunchedEffect(smallTitleVisible) { + if (smallTitleVisible) { + val showSpec = folmeSpring(damping = 1.0f, response = 0.3f) + launch { smallTitleAlpha.animateTo(1f, showSpec) } + launch { smallTitleTranslationY.animateTo(0f, showSpec) } + } else { + val hideSpec = folmeSpring(damping = 1.0f, response = 0.15f) + launch { smallTitleAlpha.animateTo(0f, hideSpec) } + launch { smallTitleTranslationY.animateTo(20f, hideSpec) } + } + } + + // Title color transition animation + val animatedTitleColor by animateColorAsState( + targetValue = titleColor, + animationSpec = tween(durationMillis = 50), + ) + val animatedLargeTitleColor by animateColorAsState( + targetValue = largeTitleColor, + animationSpec = tween(durationMillis = 50), + ) + val animatedSubtitleColor by animateColorAsState( + targetValue = subtitleColor, + animationSpec = tween(durationMillis = 50), + ) + + Layout( + { + Box( + Modifier + .layoutId("navigationIcon") + .padding(start = navigationIconPadding), + ) { + navigationIcon() + } + Box( + Modifier + .layoutId("title") + .padding(horizontal = titlePadding) + .graphicsLayer { + alpha = smallTitleAlpha.value + translationY = smallTitleTranslationY.value + }, + ) { + Text( + text = title, + color = animatedTitleColor, + fontSize = MiuixTheme.textStyles.title3.fontSize, + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + } + Box( + Modifier + .layoutId("actionIcons") + .padding(end = actionIconPadding), + ) { + actions() + } + Box( + Modifier + .layoutId("largeTitle") + .padding(top = TopAppBarDefaults.CollapsedHeight) + .padding(horizontal = titlePadding) + .graphicsLayer { alpha = largeTitleAlpha }, + ) { + Column( + modifier = Modifier + .offset { IntOffset(0, heightOffset) } + .onSizeChanged { largeTitleHeight.value = it.height }, + ) { + Text( + text = largeTitle, + color = animatedLargeTitleColor, + fontSize = MiuixTheme.textStyles.title1.fontSize, + fontWeight = FontWeight.Normal, + ) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + color = animatedSubtitleColor, + style = MiuixTheme.textStyles.body2, + ) + } + } + } + if (subtitle.isNotEmpty()) { + // Small subtitle: appears with small title when collapsed + Box( + Modifier + .layoutId("smallSubtitle") + .graphicsLayer { + alpha = smallTitleAlpha.value + translationY = smallTitleTranslationY.value + }, + ) { + Text( + text = subtitle, + color = animatedSubtitleColor, + style = MiuixTheme.textStyles.body2, + ) + } + } + Box(Modifier.layoutId("bottomContent")) { + bottomContent() + } + }, + modifier = modifier + .then(Modifier.background(color)) + .then( + if (defaultWindowInsetsPadding) { + Modifier + .windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + } else { + Modifier + }, + ) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .clipToBounds() + .pointerInput(Unit) { + detectTapGestures { /* Consume click */ } + }, + ) { measurables, constraints -> + val navigationIconPlaceable = + measurables + .fastFirst { it.layoutId == "navigationIcon" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val actionIconsPlaceable = + measurables + .fastFirst { it.layoutId == "actionIcons" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val maxTitleWidth = constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width + + val titlePlaceable = + measurables + .fastFirst { it.layoutId == "title" } + .measure(constraints.copy(minWidth = 0, maxWidth = (maxTitleWidth * 0.9).roundToInt(), minHeight = 0)) + + val largeTitlePlaceable = + measurables + .fastFirst { it.layoutId == "largeTitle" } + .measure( + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = Constraints.Infinity, + ), + ) + + val smallSubtitlePlaceable = + measurables + .firstOrNull { it.layoutId == "smallSubtitle" } + ?.measure(constraints.copy(minWidth = 0, maxWidth = (maxTitleWidth * 0.9).roundToInt(), minHeight = 0)) + + val bottomContentPlaceable = + measurables + .fastFirst { it.layoutId == "bottomContent" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val collapsedHeight = TopAppBarDefaults.CollapsedHeight.roundToPx() + val expandedHeight = maxOf( + collapsedHeight, + largeTitlePlaceable.height, + ) + + val barHeight = lerp( + start = collapsedHeight, + stop = expandedHeight, + fraction = if (expandedHeightPx > 0f) { + val offset = scrolledOffset.offset() + if (offset.isNaN()) 1f else (1f - (abs(offset) / expandedHeightPx).coerceIn(0f, 1f)) + } else { + 1f + }, + ).toFloat().roundToInt() + + val verticalCenter = collapsedHeight / 2 + val smallSubtitleHeight = smallSubtitlePlaceable?.height ?: 0 + val smallSubtitleBottom = verticalCenter + titlePlaceable.height / 2 + smallSubtitleHeight + val expandedBottomPadding = if (smallSubtitlePlaceable != null) { + TopAppBarDefaults.SubtitleBottomPadding.roundToPx() + } else { + TopAppBarDefaults.LargeTitleBottomPadding.roundToPx() + } + val contentTop = maxOf(barHeight + expandedBottomPadding, smallSubtitleBottom + expandedBottomPadding) + val layoutHeight = contentTop + bottomContentPlaceable.height + + layout(constraints.maxWidth, layoutHeight) { + // Navigation icon + navigationIconPlaceable.placeRelative( + x = 0, + y = verticalCenter - navigationIconPlaceable.height / 2, + ) + + // Title + var baseX = (constraints.maxWidth - titlePlaceable.width) / 2 + if (baseX < navigationIconPlaceable.width) { + baseX += (navigationIconPlaceable.width - baseX) + } else if (baseX + titlePlaceable.width > constraints.maxWidth - actionIconsPlaceable.width) { + baseX += ((constraints.maxWidth - actionIconsPlaceable.width) - (baseX + titlePlaceable.width)) + } + titlePlaceable.placeRelative( + x = baseX, + y = verticalCenter - titlePlaceable.height / 2, + ) + + // Small subtitle (centered below small title, same alpha as small title) + smallSubtitlePlaceable?.placeRelative( + x = (constraints.maxWidth - smallSubtitlePlaceable.width) / 2, + y = verticalCenter + titlePlaceable.height / 2, + ) + + // Action icons + actionIconsPlaceable.placeRelative( + x = constraints.maxWidth - actionIconsPlaceable.width, + y = verticalCenter - actionIconsPlaceable.height / 2, + ) + + // Large title (includes large subtitle in a Column) + largeTitlePlaceable.placeRelative( + x = 0, + y = 0, + ) + + // Bottom content (pinned, below bar and subtitle) + bottomContentPlaceable.placeRelative( + x = 0, + y = contentTop, + ) + } + } +} + +/** + * The base [Layout] for [SmallTopAppBar]. This function lays out a [SmallTopAppBar] navigation icon + * (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and + * the actions are optional. + * + * @param title the [SmallTopAppBar] title (header). + * @param color the background color of the [SmallTopAppBar]. + * @param titleColor the color of the title text. + * @param subtitle the subtitle text displayed below the title bar area. + * @param subtitleColor the color of the subtitle text. + * @param navigationIcon a navigation icon [Composable]. + * @param actions actions [Composable]. + * @param titlePadding the horizontal padding of the [SmallTopAppBar]'s title. + * @param navigationIconPadding the start padding of the navigation icon. + * @param actionIconPadding the end padding of the action icons. + * @param modifier the [Modifier] to be applied to this layout. + * @param defaultWindowInsetsPadding whether to apply default window insets padding to the [SmallTopAppBar]. + * @param bottomContent the composable content displayed below the title bar area. + */ +@Composable +private fun SmallTopAppBarLayout( + title: String, + color: Color, + titleColor: Color, + subtitle: String, + subtitleColor: Color, + navigationIcon: @Composable () -> Unit, + actions: @Composable () -> Unit, + titlePadding: Dp, + navigationIconPadding: Dp, + actionIconPadding: Dp, + modifier: Modifier = Modifier, + defaultWindowInsetsPadding: Boolean = true, + bottomContent: @Composable () -> Unit = {}, +) { + val titleModifier = remember(titlePadding) { + Modifier + .layoutId("title") + .padding(horizontal = titlePadding) + } + + // Title color transition animation + val animatedTitleColor by animateColorAsState( + targetValue = titleColor, + animationSpec = tween(durationMillis = 50), + ) + val animatedSubtitleColor by animateColorAsState( + targetValue = subtitleColor, + animationSpec = tween(durationMillis = 50), + ) + + Layout( + { + Box( + Modifier + .layoutId("navigationIcon") + .padding(start = navigationIconPadding), + ) { + navigationIcon() + } + Box(titleModifier) { + Text( + text = title, + color = animatedTitleColor, + maxLines = 1, + fontSize = MiuixTheme.textStyles.title3.fontSize, + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + } + Box( + Modifier + .layoutId("actionIcons") + .padding(end = actionIconPadding), + ) { + actions() + } + if (subtitle.isNotEmpty()) { + Box(Modifier.layoutId("subtitle")) { + Text( + text = subtitle, + color = animatedSubtitleColor, + style = MiuixTheme.textStyles.body2, + ) + } + } + Box(Modifier.layoutId("bottomContent")) { + bottomContent() + } + }, + modifier = modifier + .then(Modifier.background(color)) + .then( + if (defaultWindowInsetsPadding) { + Modifier + .windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + } else { + Modifier + }, + ) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .clipToBounds() + .pointerInput(Unit) { + detectTapGestures { /* Consume click */ } + }, + ) { measurables, constraints -> + val navigationIconPlaceable = + measurables + .fastFirst { it.layoutId == "navigationIcon" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val actionIconsPlaceable = + measurables + .fastFirst { it.layoutId == "actionIcons" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val maxTitleWidth = constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width + + val titlePlaceable = + measurables + .fastFirst { it.layoutId == "title" } + .measure(constraints.copy(minWidth = 0, maxWidth = (maxTitleWidth * 0.9).roundToInt(), minHeight = 0)) + + val subtitlePlaceable = + measurables + .firstOrNull { it.layoutId == "subtitle" } + ?.measure(constraints.copy(minWidth = 0, maxWidth = (maxTitleWidth * 0.9).roundToInt(), minHeight = 0)) + + val bottomContentPlaceable = + measurables + .fastFirst { it.layoutId == "bottomContent" } + .measure(constraints.copy(minWidth = 0, minHeight = 0)) + + val subtitleHeight = subtitlePlaceable?.height ?: 0 + val collapsedHeight = TopAppBarDefaults.CollapsedHeight.roundToPx() + val verticalCenter = TopAppBarDefaults.SmallTopAppBarCenterHeight.roundToPx() / 2 + val subtitleY = verticalCenter + titlePlaceable.height / 2 + val subtitleBottomPadding = if (subtitlePlaceable != null) TopAppBarDefaults.SubtitleBottomPadding.roundToPx() else 0 + val contentTop = maxOf(collapsedHeight, subtitleY + subtitleHeight + subtitleBottomPadding) + val layoutHeight = contentTop + bottomContentPlaceable.height + + layout(constraints.maxWidth, layoutHeight) { + // Navigation icon + navigationIconPlaceable.placeRelative( + x = 0, + y = verticalCenter - navigationIconPlaceable.height / 2, + ) + + // Title + var baseX = (constraints.maxWidth - titlePlaceable.width) / 2 + if (baseX < navigationIconPlaceable.width) { + baseX += (navigationIconPlaceable.width - baseX) + } else if (baseX + titlePlaceable.width > constraints.maxWidth - actionIconsPlaceable.width) { + baseX += ((constraints.maxWidth - actionIconsPlaceable.width) - (baseX + titlePlaceable.width)) + } + titlePlaceable.placeRelative( + x = baseX, + y = verticalCenter - titlePlaceable.height / 2, + ) + + // Action icons + actionIconsPlaceable.placeRelative( + x = constraints.maxWidth - actionIconsPlaceable.width, + y = verticalCenter - actionIconsPlaceable.height / 2, + ) + + // Subtitle (centered, right below title) + subtitlePlaceable?.placeRelative( + x = (constraints.maxWidth - subtitlePlaceable.width) / 2, + y = subtitleY, + ) + + // Bottom content (below subtitle) + bottomContentPlaceable.placeRelative( + x = 0, + y = contentTop, + ) + } + } +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d0567020..c50b5de1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -112,6 +112,7 @@ dependencies { androidTestImplementation(composeBom) implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt index cffca1f9..bc030e56 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/PrefKeys.kt @@ -22,6 +22,8 @@ object PrefKeys { const val DEFAULT_ENABLE_FLOAT = "pref_default_enable_float" const val DEFAULT_SHOW_ISLAND_ICON = "pref_default_show_island_icon" const val DEFAULT_MARQUEE = "pref_default_marquee" + const val DEFAULT_DYNAMIC_HIGHLIGHT_COLOR = "pref_default_dynamic_highlight_color" + const val DEFAULT_OUTER_GLOW = "pref_default_outer_glow" const val DEFAULT_FOCUS_NOTIF = "pref_default_focus_notif" const val DEFAULT_PRESERVE_SMALL_ICON = "pref_default_preserve_small_icon" const val DEFAULT_RESTORE_LOCKSCREEN = "pref_default_restore_lockscreen" diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt index 795b15f8..de59c93b 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt @@ -37,6 +37,8 @@ class SettingsRepository(private val context: Context) { defaultEnableFloat = prefs.getBoolean(PrefKeys.DEFAULT_ENABLE_FLOAT, false), defaultShowIslandIcon = prefs.getBoolean(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, true), defaultMarquee = prefs.getBoolean(PrefKeys.DEFAULT_MARQUEE, false), + defaultDynamicHighlightColor = prefs.getBoolean(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, false), + defaultOuterGlow = prefs.getBoolean(PrefKeys.DEFAULT_OUTER_GLOW, false), defaultFocusNotif = prefs.getBoolean(PrefKeys.DEFAULT_FOCUS_NOTIF, true), defaultPreserveSmallIcon = prefs.getBoolean(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, false), defaultRestoreLockscreen = prefs.getBoolean(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, false), diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt index 0ae9bf55..6371986f 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt @@ -21,6 +21,8 @@ data class SettingsState( val defaultEnableFloat: Boolean = false, val defaultShowIslandIcon: Boolean = true, val defaultMarquee: Boolean = false, + val defaultDynamicHighlightColor: Boolean = false, + val defaultOuterGlow: Boolean = false, val defaultFocusNotif: Boolean = true, val defaultPreserveSmallIcon: Boolean = false, val defaultRestoreLockscreen: Boolean = false, diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index fd135233..b87443ab 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -17,10 +17,12 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.clickable import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -98,8 +100,12 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -122,6 +128,7 @@ import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import io.github.hyperisland.data.prefs.PrefKeys import io.github.hyperisland.data.prefs.SettingsState import io.github.hyperisland.ui.ai.AiConfigScreen @@ -174,6 +181,7 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.pressable import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import kotlinx.coroutines.delay import kotlin.math.cos import kotlin.math.sin @@ -408,6 +416,12 @@ private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 600 private const val ROUTE_TRANSITION_DURATION_MS = 280 private const val OVERLAY_TRANSITION_DURATION_MS = 320 +private fun resolveNightMode(themeMode: String): Int = when (themeMode) { + "light" -> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM +} + @Composable private fun HyperCeilerNavItem( destination: TopLevelDestination, @@ -465,6 +479,7 @@ private fun HyperCeilerNavigationSwitchBar( modifier: Modifier = Modifier, backdrop: LayerBackdrop? = null, ) { + val isDarkTheme = isSystemInDarkTheme() val navBottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() AnimatedContent( targetState = style.floating, @@ -513,19 +528,27 @@ private fun HyperCeilerNavigationSwitchBar( .shadow( elevation = style.floatingShadowElevation, shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), - ambientColor = Color.Black.copy(alpha = 0.10f), - spotColor = Color.Black.copy(alpha = 0.12f), + ambientColor = Color.Black.copy(alpha = if (isDarkTheme) 0.34f else 0.10f), + spotColor = Color.Black.copy(alpha = if (isDarkTheme) 0.42f else 0.12f), clip = false, ) .clip(androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius)) .let { if (backdrop != null) it.textureBlur(backdrop, shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), blurRadiusX = 32f, blurRadiusY = 32f) else it } .background( brush = Brush.verticalGradient( - colors = listOf( - Color(0xFFFFFFFF).copy(alpha = if (backdrop != null) 0.65f else 1f), - Color(0xFFFAFAFB).copy(alpha = if (backdrop != null) 0.65f else 1f), - Color(0xFFF3F4F6).copy(alpha = if (backdrop != null) 0.65f else 1f), - ), + colors = if (isDarkTheme) { + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.78f else 0.96f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (backdrop != null) 0.70f else 0.90f), + MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.66f else 0.86f), + ) + } else { + listOf( + Color(0xFFFFFFFF).copy(alpha = if (backdrop != null) 0.65f else 1f), + Color(0xFFFAFAFB).copy(alpha = if (backdrop != null) 0.65f else 1f), + Color(0xFFF3F4F6).copy(alpha = if (backdrop != null) 0.65f else 1f), + ) + }, ), ) .drawWithCache { @@ -538,25 +561,50 @@ private fun HyperCeilerNavigationSwitchBar( val strokeAngleRad = Math.toRadians(34.0) val dx = (cos(strokeAngleRad) * halfW).toFloat() val dy = (sin(strokeAngleRad) * halfH).toFloat() - val outerStrokeBrush = Brush.linearGradient( - colors = listOf( - Color.White.copy(alpha = 0.97f), - Color(0xFFF1F2F5).copy(alpha = 0.78f), - Color(0xFFE2E4E9).copy(alpha = 0.62f), - Color.White.copy(alpha = 0.93f), - ), - start = Offset(halfW - dx, halfH - dy), - end = Offset(halfW + dx, halfH + dy), - ) - val innerStrokeBrush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.84f), - Color.White.copy(alpha = 0.28f), - Color.Transparent, - ), - startY = 0f, - endY = size.height * 0.70f, - ) + val outerStrokeBrush = if (isDarkTheme) { + Brush.linearGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.10f), + Color.Black.copy(alpha = 0.14f), + Color.White.copy(alpha = 0.18f), + ), + start = Offset(halfW - dx, halfH - dy), + end = Offset(halfW + dx, halfH + dy), + ) + } else { + Brush.linearGradient( + colors = listOf( + Color.White.copy(alpha = 0.97f), + Color(0xFFF1F2F5).copy(alpha = 0.78f), + Color(0xFFE2E4E9).copy(alpha = 0.62f), + Color.White.copy(alpha = 0.93f), + ), + start = Offset(halfW - dx, halfH - dy), + end = Offset(halfW + dx, halfH + dy), + ) + } + val innerStrokeBrush = if (isDarkTheme) { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f), + Color.Transparent, + ), + startY = 0f, + endY = size.height * 0.70f, + ) + } else { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.84f), + Color.White.copy(alpha = 0.28f), + Color.Transparent, + ), + startY = 0f, + endY = size.height * 0.70f, + ) + } onDrawWithContent { drawContent() drawRoundRect( @@ -638,6 +686,36 @@ private fun routeTitle(route: String?): String { } } +@Composable +private fun OverlayPopupMenuContainer(content: @Composable () -> Unit) { + val isDarkTheme = isSystemInDarkTheme() + val containerShape = RoundedCornerShape(16.dp) + Box( + modifier = Modifier + .clip(containerShape) + .background( + MaterialTheme.colorScheme.surface.copy( + alpha = if (isDarkTheme) 0.95f else 0.98f, + ), + ) + .then( + if (isDarkTheme) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.42f), + shape = containerShape, + ) + } else { + Modifier + }, + ), + ) { + MiuixListPopupColumn { + content() + } + } +} + private fun openExternalUrl(context: Context, url: String) { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { @@ -658,6 +736,12 @@ private fun HyperIslandComposeApp() { val blacklistVm: BlacklistViewModel = viewModel() val settingsVm: SettingsViewModel = viewModel() val settingsState by settingsVm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(settingsState.themeMode) { + val targetMode = resolveNightMode(settingsState.themeMode) + if (AppCompatDelegate.getDefaultNightMode() != targetMode) { + AppCompatDelegate.setDefaultNightMode(targetMode) + } + } val appsState by appsVm.uiState.collectAsStateWithLifecycle() val blacklistState by blacklistVm.uiState.collectAsStateWithLifecycle() var showRestartDialog by remember { mutableStateOf(false) } @@ -738,6 +822,49 @@ private fun HyperIslandComposeApp() { var isAppsSearchExpanded by remember { mutableStateOf(false) } var isBlacklistSearchExpanded by remember { mutableStateOf(false) } + var appsSearchFieldValue by remember { mutableStateOf(TextFieldValue("")) } + var blacklistSearchFieldValue by remember { mutableStateOf(TextFieldValue("")) } + val appsSearchFocusRequester = remember { FocusRequester() } + val blacklistSearchFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(appsState.query, isAppsSearchExpanded) { + if (!isAppsSearchExpanded) { + appsSearchFieldValue = TextFieldValue( + text = appsState.query, + selection = TextRange(appsState.query.length), + ) + } + } + LaunchedEffect(blacklistState.query, isBlacklistSearchExpanded) { + if (!isBlacklistSearchExpanded) { + blacklistSearchFieldValue = TextFieldValue( + text = blacklistState.query, + selection = TextRange(blacklistState.query.length), + ) + } + } + + LaunchedEffect(isAppsSearchExpanded) { + if (isAppsSearchExpanded) { + appsSearchFieldValue = appsSearchFieldValue.copy( + selection = TextRange(appsSearchFieldValue.text.length), + ) + delay(120) + appsSearchFocusRequester.requestFocus() + keyboardController?.show() + } + } + LaunchedEffect(isBlacklistSearchExpanded) { + if (isBlacklistSearchExpanded) { + blacklistSearchFieldValue = blacklistSearchFieldValue.copy( + selection = TextRange(blacklistSearchFieldValue.text.length), + ) + delay(120) + blacklistSearchFocusRequester.requestFocus() + keyboardController?.show() + } + } val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route @@ -1007,7 +1134,7 @@ private fun HyperIslandComposeApp() { appChannelsBatchRequestId += 1 }, ) - MiuixListPopupColumn { + OverlayPopupMenuContainer { menuItems.forEachIndexed { index, (title, action) -> MiuixDropdownImpl( text = title, @@ -1021,7 +1148,17 @@ private fun HyperIslandComposeApp() { } } } else if (currentRoute == "blacklist") { - MiuixIconButton(onClick = { isBlacklistSearchExpanded = !isBlacklistSearchExpanded }) { + MiuixIconButton( + onClick = { + if (!isBlacklistSearchExpanded) { + blacklistSearchFieldValue = TextFieldValue( + text = blacklistState.query, + selection = TextRange(blacklistState.query.length), + ) + } + isBlacklistSearchExpanded = !isBlacklistSearchExpanded + }, + ) { Icon( imageVector = MiuixIcons.Regular.Search, contentDescription = "搜索", @@ -1065,7 +1202,7 @@ private fun HyperIslandComposeApp() { blacklistVm.refresh() }, ) - MiuixListPopupColumn { + OverlayPopupMenuContainer { menuItems.forEachIndexed { index, (title, action) -> MiuixDropdownImpl( text = title, @@ -1103,7 +1240,17 @@ private fun HyperIslandComposeApp() { } TopBarVariant.PrimaryApps -> { - MiuixIconButton(onClick = { isAppsSearchExpanded = !isAppsSearchExpanded }) { + MiuixIconButton( + onClick = { + if (!isAppsSearchExpanded) { + appsSearchFieldValue = TextFieldValue( + text = appsState.query, + selection = TextRange(appsState.query.length), + ) + } + isAppsSearchExpanded = !isAppsSearchExpanded + }, + ) { Icon( imageVector = MiuixIcons.Regular.Search, contentDescription = "搜索", @@ -1176,7 +1323,7 @@ private fun HyperIslandComposeApp() { }, ) } - MiuixListPopupColumn { + OverlayPopupMenuContainer { menuItems.forEachIndexed { index, (title, action) -> MiuixDropdownImpl( text = title, @@ -1214,11 +1361,15 @@ private fun HyperIslandComposeApp() { label = "apps_search_bar_visibility", ) { MiuixTextField( - value = appsState.query, - onValueChange = appsVm::setQuery, + value = appsSearchFieldValue, + onValueChange = { + appsSearchFieldValue = it + appsVm.setQuery(it.text) + }, label = "搜索应用 / 包名", useLabelAsPlaceholder = true, modifier = Modifier + .focusRequester(appsSearchFocusRequester) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) @@ -1275,11 +1426,15 @@ private fun HyperIslandComposeApp() { label = "blacklist_search_bar_visibility", ) { MiuixTextField( - value = blacklistState.query, - onValueChange = blacklistVm::setQuery, + value = blacklistSearchFieldValue, + onValueChange = { + blacklistSearchFieldValue = it + blacklistVm.setQuery(it.text) + }, label = "搜索应用 / 包名", useLabelAsPlaceholder = true, modifier = Modifier + .focusRequester(blacklistSearchFocusRequester) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) @@ -1880,6 +2035,8 @@ private fun HomeScreen( @Composable private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + val panelShape = RoundedCornerShape(16.dp) val qrBitmap = remember { runCatching { context.assets.open("flutter_assets/assets/images/wechat.jpg").use { stream -> @@ -1903,14 +2060,50 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { .scrollEndHaptic(), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + Text( + text = "支持项目持续更新,感谢你的认可。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) if (qrBitmap != null) { - Image( - bitmap = qrBitmap.asImageBitmap(), - contentDescription = "微信赞助二维码", - modifier = Modifier.fillMaxWidth(), - ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(panelShape) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = if (isDarkTheme) 0.28f else 0.18f, + ), + ) + .then( + if (isDarkTheme) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), + panelShape, + ) + } else { + Modifier + }, + ) + .padding(8.dp), + ) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "微信赞助二维码", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + ) + } } else { - Text("未找到赞助图片 assets/images/wechat.jpg") + Text( + text = "未找到赞助图片 assets/images/wechat.jpg", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } MiuixButton( onClick = onDismiss, @@ -1928,6 +2121,8 @@ private fun RestartScopeDialog( onDismiss: () -> Unit, onConfirm: (Boolean, Boolean, Boolean) -> Unit, ) { + val isDarkTheme = isSystemInDarkTheme() + val scopeCardShape = RoundedCornerShape(16.dp) var restartSystemUi by remember { mutableStateOf(true) } var restartDownloads by remember { mutableStateOf(true) } var restartXmsf by remember { mutableStateOf(true) } @@ -1949,16 +2144,35 @@ private fun RestartScopeDialog( .overScrollVertical() .scrollEndHaptic(), ) { + Text( + text = "选择后将依次重启对应进程并刷新岛通知能力。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) Column( - verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .fillMaxWidth() - .padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 16.dp, - ), + .clip(scopeCardShape) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = if (isDarkTheme) 0.22f else 0.12f, + ), + ) + .then( + if (isDarkTheme) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), + scopeCardShape, + ) + } else { + Modifier + }, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { ScopeCheckboxRow( title = "系统界面", @@ -2167,6 +2381,16 @@ private fun SettingsScreen( "超级岛消息过长是否滚动显示", state.defaultMarquee, ) { onToggle(PrefKeys.DEFAULT_MARQUEE, it) } + ToggleItem( + "高亮动态取色", + "开启后默认使用图标自动取色", + state.defaultDynamicHighlightColor, + ) { onToggle(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, it) } + ToggleItem( + "外圈光效", + "", + state.defaultOuterGlow, + ) { onToggle(PrefKeys.DEFAULT_OUTER_GLOW, it) } ToggleItem( "焦点通知", "替换通知为焦点通知(关闭后显示原始通知)", @@ -2346,6 +2570,8 @@ private fun MainActivityPreview() { defaultEnableFloat = true, defaultShowIslandIcon = true, defaultMarquee = true, + defaultDynamicHighlightColor = true, + defaultOuterGlow = false, defaultFocusNotif = true, defaultPreserveSmallIcon = false, defaultRestoreLockscreen = false, @@ -2513,6 +2739,8 @@ private fun applyPreviewToggle(state: SettingsState, key: String, enabled: Boole PrefKeys.DEFAULT_FIRST_FLOAT -> state.copy(defaultFirstFloat = enabled) PrefKeys.DEFAULT_ENABLE_FLOAT -> state.copy(defaultEnableFloat = enabled) PrefKeys.DEFAULT_MARQUEE -> state.copy(defaultMarquee = enabled) + PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR -> state.copy(defaultDynamicHighlightColor = enabled) + PrefKeys.DEFAULT_OUTER_GLOW -> state.copy(defaultOuterGlow = enabled) PrefKeys.DEFAULT_FOCUS_NOTIF -> state.copy(defaultFocusNotif = enabled) PrefKeys.DEFAULT_RESTORE_LOCKSCREEN -> state.copy(defaultRestoreLockscreen = enabled) PrefKeys.DEFAULT_SHOW_ISLAND_ICON -> state.copy(defaultShowIslandIcon = enabled) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt index bcef0227..a07b2b3b 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt @@ -186,6 +186,10 @@ class AppAdaptationRepository(private val context: Context) { "default", ) ?: "default", highlightColor = prefs.getString("pref_channel_highlight_color_${packageName}_$channelId", "") ?: "", + dynamicHighlightColor = prefs.getString( + "pref_channel_dynamic_highlight_color_${packageName}_$channelId", + "default", + ) ?: "default", showLeftHighlight = prefs.getString( "pref_channel_show_left_highlight_${packageName}_$channelId", "off", @@ -194,6 +198,18 @@ class AppAdaptationRepository(private val context: Context) { "pref_channel_show_right_highlight_${packageName}_$channelId", "off", ) ?: "off", + showLeftNarrowFont = prefs.getString( + "pref_channel_show_left_narrow_font_${packageName}_$channelId", + "off", + ) ?: "off", + showRightNarrowFont = prefs.getString( + "pref_channel_show_right_narrow_font_${packageName}_$channelId", + "off", + ) ?: "off", + outerGlow = prefs.getString( + "pref_channel_outer_glow_${packageName}_$channelId", + "default", + ) ?: "default", ) } @@ -210,8 +226,12 @@ class AppAdaptationRepository(private val context: Context) { "renderer" -> "pref_channel_renderer_${packageName}_$channelId" "restore_lockscreen" -> "pref_channel_restore_lockscreen_${packageName}_$channelId" "highlight_color" -> "pref_channel_highlight_color_${packageName}_$channelId" + "dynamic_highlight_color" -> "pref_channel_dynamic_highlight_color_${packageName}_$channelId" "show_left_highlight" -> "pref_channel_show_left_highlight_${packageName}_$channelId" "show_right_highlight" -> "pref_channel_show_right_highlight_${packageName}_$channelId" + "show_left_narrow_font" -> "pref_channel_show_left_narrow_font_${packageName}_$channelId" + "show_right_narrow_font" -> "pref_channel_show_right_narrow_font_${packageName}_$channelId" + "outer_glow" -> "pref_channel_outer_glow_${packageName}_$channelId" else -> return } if (setting == "highlight_color" && value.isBlank()) { @@ -244,8 +264,12 @@ class AppAdaptationRepository(private val context: Context) { "renderer" -> "pref_channel_renderer_${packageName}_$channelId" "restore_lockscreen" -> "pref_channel_restore_lockscreen_${packageName}_$channelId" "highlight_color" -> "pref_channel_highlight_color_${packageName}_$channelId" + "dynamic_highlight_color" -> "pref_channel_dynamic_highlight_color_${packageName}_$channelId" "show_left_highlight" -> "pref_channel_show_left_highlight_${packageName}_$channelId" "show_right_highlight" -> "pref_channel_show_right_highlight_${packageName}_$channelId" + "show_left_narrow_font" -> "pref_channel_show_left_narrow_font_${packageName}_$channelId" + "show_right_narrow_font" -> "pref_channel_show_right_narrow_font_${packageName}_$channelId" + "outer_glow" -> "pref_channel_outer_glow_${packageName}_$channelId" else -> null } ?: return@forEach if (setting == "highlight_color" && value.isBlank()) { diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt index 6743966b..2f56c9bb 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt @@ -33,6 +33,10 @@ data class ChannelExtraSettings( val renderer: String = "image_text_with_buttons_4", val restoreLockscreen: String = "default", val highlightColor: String = "", + val dynamicHighlightColor: String = "default", val showLeftHighlight: String = "off", val showRightHighlight: String = "off", + val showLeftNarrowFont: String = "off", + val showRightNarrowFont: String = "off", + val outerGlow: String = "default", ) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index 6cc5ea8e..6f5a11c4 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -131,8 +131,12 @@ class AppChannelsViewModel( "marquee" -> current.copy(marquee = value) "renderer" -> current.copy(renderer = value) "restore_lockscreen" -> current.copy(restoreLockscreen = value) + "dynamic_highlight_color" -> current.copy(dynamicHighlightColor = value) "show_left_highlight" -> current.copy(showLeftHighlight = value) "show_right_highlight" -> current.copy(showRightHighlight = value) + "show_left_narrow_font" -> current.copy(showLeftNarrowFont = value) + "show_right_narrow_font" -> current.copy(showRightNarrowFont = value) + "outer_glow" -> current.copy(outerGlow = value) else -> return } repo.setChannelSetting(packageName, channelId, setting, value) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 3dc534b8..30dcfb85 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -4,6 +4,7 @@ import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -44,17 +46,22 @@ import androidx.compose.ui.draw.clip import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.foundation.isSystemInDarkTheme import io.github.hyperisland.ui.FaGlyph import io.github.hyperisland.ui.FaIcon import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults import top.yukonga.miuix.kmp.basic.Card as MiuixCard import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox -import top.yukonga.miuix.kmp.basic.ColorPalette as MiuixColorPalette import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh @@ -76,6 +83,7 @@ import top.yukonga.miuix.kmp.blur.textureBlur import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.pressable import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import kotlinx.coroutines.delay @Composable fun AppsScreen( @@ -667,6 +675,7 @@ private fun ChannelSettingsContent( ) val templateOptions = listOf( + "generic_progress" to "下载", "notification_island" to "通知超级岛", "notification_island_lite" to "通知超级岛 Lite", "download_lite" to "下载 Lite", @@ -685,62 +694,20 @@ private fun ChannelSettingsContent( val focusOptions = triStateOptions(defaultOn = true) val preserveStatusBarOptions = triStateOptions(defaultOn = false) val restoreLockscreenOptions = triStateOptions(defaultOn = false) + val dynamicHighlightOptions = listOf( + "default" to "默认(关闭)", + "on" to "开启", + "off" to "关闭", + "dark" to "暗", + "darker" to "更暗", + ) + val outerGlowOptions = triStateOptions(defaultOn = false) val rendererOptions = listOf( "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", "image_text_with_buttons_4_wrap" to "封面信息样式", "image_text_with_right_text_button" to "图文右侧文本按钮", ) var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } - var showColorPaletteDialog by remember { mutableStateOf(false) } - var dialogPaletteColor by remember { mutableStateOf(parseHexToColor(extras.highlightColor) ?: Color(0xFFFF3B30)) } - val paletteColor = remember(highlightDraft) { - parseHexToColor(highlightDraft) ?: Color(0xFFFF3B30) - } - - BackHandler(enabled = showColorPaletteDialog) { - showColorPaletteDialog = false - } - - OverlayDialog( - title = "选择高亮颜色", - show = showColorPaletteDialog, - onDismissRequest = { showColorPaletteDialog = false }, - renderInRootScaffold = false, - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - MiuixColorPalette( - color = dialogPaletteColor, - onColorChanged = { dialogPaletteColor = it }, - showPreview = true, - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TextButton( - onClick = { showColorPaletteDialog = false }, - modifier = Modifier.weight(1f), - ) { - Text("取消") - } - TextButton( - onClick = { - val hex = dialogPaletteColor.toHexRgbString() - highlightDraft = hex - onSetHighlightColor(hex) - showColorPaletteDialog = false - }, - modifier = Modifier.weight(1f), - ) { - Text("确定") - } - } - } - } Column( modifier = Modifier @@ -778,39 +745,28 @@ private fun ChannelSettingsContent( SettingsDropdownRow("消息滚动", marqueeOptions, extras.marquee, true, largeText = true) { onSetSetting("marquee", it) } - MiuixTextField( + InputDialogRow( + title = "自动消失时长", + subtitle = "点击后在对话框中输入 1-30 秒", value = timeout, - onValueChange = { onSetTimeout(it) }, - label = "自动消失时长(1-30秒)", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + emptyValueText = "5", + dialogTitle = "修改自动消失时长", + dialogDescription = "值应该大于等于 1 并小于等于 30", + onConfirm = { onSetTimeout(it.trim()) }, + ) + InputDialogRow( + title = "高亮颜色", + subtitle = "点击后输入 #RRGGBB,留空可清空", + value = highlightDraft, + emptyValueText = "未设置", + dialogTitle = "修改高亮颜色", + dialogDescription = "请输入 #RRGGBB 格式,留空可清空当前颜色", + onConfirm = { + val next = it.trim() + highlightDraft = next + onSetHighlightColor(next) + }, ) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - MiuixTextField( - value = highlightDraft, - onValueChange = { - highlightDraft = it - onSetHighlightColor(it) - }, - label = "高亮颜色(#RRGGBB,可空)", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier.weight(1f), - ) - MiuixButton( - onClick = { - dialogPaletteColor = paletteColor - showColorPaletteDialog = true - }, - ) { - Text("调色盘") - } - } SwitchSettingRow( title = "左侧高亮", checked = extras.showLeftHighlight == "on", @@ -821,6 +777,25 @@ private fun ChannelSettingsContent( checked = extras.showRightHighlight == "on", onCheckedChange = { onSetSetting("show_right_highlight", if (it) "on" else "off") }, ) + SettingsDropdownRow( + "高亮动态取色", + dynamicHighlightOptions, + extras.dynamicHighlightColor, + true, + largeText = true, + ) { + onSetSetting("dynamic_highlight_color", it) + } + SwitchSettingRow( + title = "左侧窄字体", + checked = extras.showLeftNarrowFont == "on", + onCheckedChange = { onSetSetting("show_left_narrow_font", if (it) "on" else "off") }, + ) + SwitchSettingRow( + title = "右侧窄字体", + checked = extras.showRightNarrowFont == "on", + onCheckedChange = { onSetSetting("show_right_narrow_font", if (it) "on" else "off") }, + ) } ChannelSectionTitle("焦点通知") @@ -839,6 +814,9 @@ private fun ChannelSettingsContent( SettingsDropdownRow("锁屏通知恢复", restoreLockscreenOptions, extras.restoreLockscreen, true, largeText = true) { onSetSetting("restore_lockscreen", it) } + SettingsDropdownRow("外圈光效", outerGlowOptions, extras.outerGlow, true, largeText = true) { + onSetSetting("outer_glow", it) + } } } } @@ -853,7 +831,24 @@ private fun ChannelSectionTitle(title: String) { @Composable private fun ChannelSectionCard(content: @Composable () -> Unit) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + val isDarkTheme = isSystemInDarkTheme() + val cardShape = RoundedCornerShape(18.dp) + MiuixCard( + modifier = Modifier + .fillMaxWidth() + .clip(cardShape) + .then( + if (isDarkTheme) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + shape = cardShape, + ) + } else { + Modifier + }, + ), + ) { Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -863,20 +858,6 @@ private fun ChannelSectionCard(content: @Composable () -> Unit) { } } -private fun parseHexToColor(value: String): Color? { - val text = value.trim() - if (text.isBlank()) return null - val normalized = if (text.startsWith("#")) text else "#$text" - return runCatching { Color(android.graphics.Color.parseColor(normalized)) }.getOrNull() -} - -private fun Color.toHexRgbString(): String { - val r = (red * 255f).toInt().coerceIn(0, 255) - val g = (green * 255f).toInt().coerceIn(0, 255) - val b = (blue * 255f).toInt().coerceIn(0, 255) - return "#%02X%02X%02X".format(r, g, b) -} - @Composable private fun SettingsDropdownRow( title: String, @@ -901,6 +882,138 @@ private fun SettingsDropdownRow( ) } +@Composable +private fun InputDialogRow( + title: String, + subtitle: String, + value: String, + emptyValueText: String, + dialogTitle: String, + dialogDescription: String, + onConfirm: (String) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + var draft by remember(value) { mutableStateOf(value) } + val inputFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val displayValue = value.ifBlank { emptyValueText } + val titleColor = MiuixTheme.colorScheme.onBackground + val summaryColor = MiuixTheme.colorScheme.onSurfaceVariantSummary + val valueColor = MiuixTheme.colorScheme.onSurfaceVariantActions + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clickable { + draft = value + showDialog = true + } + .padding(start = 16.dp, end = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + fontSize = MiuixTheme.textStyles.headline1.fontSize, + fontWeight = FontWeight.Medium, + color = titleColor, + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = summaryColor, + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = displayValue, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = valueColor, + ) + Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = null, + tint = valueColor, + ) + } + } + + OverlayDialog( + title = dialogTitle, + show = showDialog, + onDismissRequest = { showDialog = false }, + renderInRootScaffold = false, + ) { + LaunchedEffect(showDialog) { + if (showDialog) { + delay(120) + inputFocusRequester.requestFocus() + keyboardController?.show() + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (dialogDescription.isNotBlank()) { + Text( + text = dialogDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + MiuixTextField( + value = draft, + onValueChange = { draft = it }, + label = title, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .focusRequester(inputFocusRequester), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + MiuixButton( + onClick = { showDialog = false }, + modifier = Modifier.weight(1f), + ) { + Text("取消") + } + MiuixButton( + onClick = { + onConfirm(draft) + showDialog = false + }, + modifier = Modifier.weight(1f), + colors = MiuixButtonDefaults.buttonColorsPrimary(), + ) { + Text( + text = "确认", + color = MiuixTheme.colorScheme.onPrimary, + ) + } + } + } + } +} + @Composable private fun BatchApplyDialog( title: String, @@ -914,6 +1027,11 @@ private fun BatchApplyDialog( "on" to "开启", "off" to "关闭", ) + val toggleOptions = listOf( + noChange to "不更改", + "on" to "开启", + "off" to "关闭", + ) val iconModeOptions = listOf( noChange to "不更改", "auto" to "自动", @@ -923,6 +1041,7 @@ private fun BatchApplyDialog( ) val templateOptions = listOf( noChange to "不更改", + "generic_progress" to "下载", "notification_island" to "通知超级岛", "notification_island_lite" to "通知超级岛 Lite", "download_lite" to "下载 Lite", @@ -934,6 +1053,14 @@ private fun BatchApplyDialog( "image_text_with_buttons_4_wrap" to "封面信息样式", "image_text_with_right_text_button" to "图文右侧文本按钮", ) + val dynamicHighlightOptions = listOf( + noChange to "不更改", + "default" to "默认", + "on" to "开启", + "off" to "关闭", + "dark" to "暗", + "darker" to "更暗", + ) var template by remember { mutableStateOf(noChange) } var renderer by remember { mutableStateOf(noChange) } @@ -946,9 +1073,13 @@ private fun BatchApplyDialog( var firstFloat by remember { mutableStateOf(noChange) } var enableFloat by remember { mutableStateOf(noChange) } var marquee by remember { mutableStateOf(noChange) } + var dynamicHighlightColor by remember { mutableStateOf(noChange) } var restoreLockscreen by remember { mutableStateOf(noChange) } - var showLeftHighlight by remember { mutableStateOf(false) } - var showRightHighlight by remember { mutableStateOf(false) } + var outerGlow by remember { mutableStateOf(noChange) } + var showLeftHighlight by remember { mutableStateOf(noChange) } + var showRightHighlight by remember { mutableStateOf(noChange) } + var showLeftNarrowFont by remember { mutableStateOf(noChange) } + var showRightNarrowFont by remember { mutableStateOf(noChange) } var highlightColor by remember { mutableStateOf("") } OverlayBottomSheet( show = true, @@ -981,9 +1112,13 @@ private fun BatchApplyDialog( putIfChanged("first_float", firstFloat) putIfChanged("enable_float", enableFloat) putIfChanged("marquee", marquee) + putIfChanged("dynamic_highlight_color", dynamicHighlightColor) putIfChanged("restore_lockscreen", restoreLockscreen) - settings["show_left_highlight"] = if (showLeftHighlight) "on" else "off" - settings["show_right_highlight"] = if (showRightHighlight) "on" else "off" + putIfChanged("outer_glow", outerGlow) + putIfChanged("show_left_highlight", showLeftHighlight) + putIfChanged("show_right_highlight", showRightHighlight) + putIfChanged("show_left_narrow_font", showLeftNarrowFont) + putIfChanged("show_right_narrow_font", showRightNarrowFont) val normalizedTimeout = timeout.trim().toIntOrNull()?.coerceIn(1, 30)?.toString() if (!normalizedTimeout.isNullOrEmpty()) { @@ -1032,32 +1167,39 @@ private fun BatchApplyDialog( SettingsDropdownRow("消息滚动", triStateOptions, marquee, true, largeText = true) { marquee = it } - MiuixTextField( + InputDialogRow( + title = "自动消失时长", + subtitle = "点击后在对话框中输入,留空表示不更改", value = timeout, - onValueChange = { timeout = it }, - label = "自动消失时长(1-30秒,留空不改)", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + emptyValueText = "不更改", + dialogTitle = "修改自动消失时长", + dialogDescription = "值应该大于等于 1 并小于等于 30,留空表示不更改", + onConfirm = { timeout = it.trim() }, ) - MiuixTextField( + InputDialogRow( + title = "高亮颜色", + subtitle = "点击后输入 #RRGGBB,留空表示不更改", value = highlightColor, - onValueChange = { highlightColor = it }, - label = "高亮颜色(#RRGGBB,留空不改)", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), - ) - SwitchSettingRow( - title = "左侧高亮", - checked = showLeftHighlight, - onCheckedChange = { showLeftHighlight = it }, - ) - SwitchSettingRow( - title = "右侧高亮", - checked = showRightHighlight, - onCheckedChange = { showRightHighlight = it }, + emptyValueText = "不更改", + dialogTitle = "修改高亮颜色", + dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", + onConfirm = { highlightColor = it.trim() }, ) + SettingsDropdownRow("高亮动态取色", dynamicHighlightOptions, dynamicHighlightColor, true, largeText = true) { + dynamicHighlightColor = it + } + SettingsDropdownRow("左侧高亮", toggleOptions, showLeftHighlight, true, largeText = true) { + showLeftHighlight = it + } + SettingsDropdownRow("右侧高亮", toggleOptions, showRightHighlight, true, largeText = true) { + showRightHighlight = it + } + SettingsDropdownRow("左侧窄字体", toggleOptions, showLeftNarrowFont, true, largeText = true) { + showLeftNarrowFont = it + } + SettingsDropdownRow("右侧窄字体", toggleOptions, showRightNarrowFont, true, largeText = true) { + showRightNarrowFont = it + } } ChannelSectionTitle("焦点通知") @@ -1072,6 +1214,9 @@ private fun BatchApplyDialog( SettingsDropdownRow("锁屏通知恢复", triStateOptions, restoreLockscreen, true, largeText = true) { restoreLockscreen = it } + SettingsDropdownRow("外圈光效", triStateOptions, outerGlow, true, largeText = true) { + outerGlow = it + } } } }, @@ -1148,11 +1293,17 @@ private fun SwitchSettingRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), + .heightIn(min = 56.dp) + .padding(start = 16.dp, end = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(title, style = MaterialTheme.typography.titleMedium) + Text( + text = title, + fontSize = MiuixTheme.textStyles.headline1.fontSize, + fontWeight = FontWeight.Medium, + color = MiuixTheme.colorScheme.onBackground, + ) MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) } } @@ -1245,8 +1396,12 @@ private fun ChannelSettingsScreenPreview() { renderer = "image_text_with_buttons_4", restoreLockscreen = "off", highlightColor = "#00C2FF", + dynamicHighlightColor = "dark", showLeftHighlight = "on", showRightHighlight = "off", + showLeftNarrowFont = "on", + showRightNarrowFont = "off", + outerGlow = "on", ), ), ), diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt index e5ed05f2..9ae04fb4 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt @@ -54,6 +54,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { PrefKeys.DEFAULT_ENABLE_FLOAT -> it.copy(defaultEnableFloat = value) PrefKeys.DEFAULT_SHOW_ISLAND_ICON -> it.copy(defaultShowIslandIcon = value) PrefKeys.DEFAULT_MARQUEE -> it.copy(defaultMarquee = value) + PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR -> it.copy(defaultDynamicHighlightColor = value) + PrefKeys.DEFAULT_OUTER_GLOW -> it.copy(defaultOuterGlow = value) PrefKeys.DEFAULT_FOCUS_NOTIF -> it.copy(defaultFocusNotif = value) PrefKeys.DEFAULT_PRESERVE_SMALL_ICON -> it.copy(defaultPreserveSmallIcon = value) PrefKeys.DEFAULT_RESTORE_LOCKSCREEN -> it.copy(defaultRestoreLockscreen = value) From 0d491195d455c685f93c6e4bfbf04d4ad2c6f693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 13:59:02 +0800 Subject: [PATCH 09/14] Compose UI: continue dark mode adaptation for cards and nav surfaces --- .../hyperisland/ui/ComposeMainActivity.kt | 50 +++++++++++++------ .../hyperisland/ui/ai/AiConfigScreen.kt | 29 +++++++++-- .../github/hyperisland/ui/app/AppsScreens.kt | 49 ++++++++++-------- .../ui/blacklist/BlacklistScreen.kt | 27 +++++++++- 4 files changed, 114 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index b87443ab..6f00b25c 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -480,6 +480,7 @@ private fun HyperCeilerNavigationSwitchBar( backdrop: LayerBackdrop? = null, ) { val isDarkTheme = isSystemInDarkTheme() + val colorScheme = MaterialTheme.colorScheme val navBottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() AnimatedContent( targetState = style.floating, @@ -528,8 +529,8 @@ private fun HyperCeilerNavigationSwitchBar( .shadow( elevation = style.floatingShadowElevation, shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), - ambientColor = Color.Black.copy(alpha = if (isDarkTheme) 0.34f else 0.10f), - spotColor = Color.Black.copy(alpha = if (isDarkTheme) 0.42f else 0.12f), + ambientColor = colorScheme.onSurface.copy(alpha = if (isDarkTheme) 0.34f else 0.10f), + spotColor = colorScheme.outline.copy(alpha = if (isDarkTheme) 0.42f else 0.12f), clip = false, ) .clip(androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius)) @@ -538,9 +539,9 @@ private fun HyperCeilerNavigationSwitchBar( brush = Brush.verticalGradient( colors = if (isDarkTheme) { listOf( - MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.78f else 0.96f), - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (backdrop != null) 0.70f else 0.90f), - MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.66f else 0.86f), + colorScheme.surface.copy(alpha = if (backdrop != null) 0.78f else 0.96f), + colorScheme.surfaceVariant.copy(alpha = if (backdrop != null) 0.70f else 0.90f), + colorScheme.surface.copy(alpha = if (backdrop != null) 0.66f else 0.86f), ) } else { listOf( @@ -564,10 +565,10 @@ private fun HyperCeilerNavigationSwitchBar( val outerStrokeBrush = if (isDarkTheme) { Brush.linearGradient( colors = listOf( - Color.White.copy(alpha = 0.22f), - Color.White.copy(alpha = 0.10f), - Color.Black.copy(alpha = 0.14f), - Color.White.copy(alpha = 0.18f), + colorScheme.outline.copy(alpha = 0.44f), + colorScheme.onSurface.copy(alpha = 0.22f), + colorScheme.surfaceVariant.copy(alpha = 0.26f), + colorScheme.outline.copy(alpha = 0.34f), ), start = Offset(halfW - dx, halfH - dy), end = Offset(halfW + dx, halfH + dy), @@ -587,8 +588,8 @@ private fun HyperCeilerNavigationSwitchBar( val innerStrokeBrush = if (isDarkTheme) { Brush.verticalGradient( colors = listOf( - Color.White.copy(alpha = 0.22f), - Color.White.copy(alpha = 0.08f), + colorScheme.onSurface.copy(alpha = 0.20f), + colorScheme.outline.copy(alpha = 0.10f), Color.Transparent, ), startY = 0f, @@ -686,6 +687,27 @@ private fun routeTitle(route: String?): String { } } +@Composable +private fun primaryCardModifier( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(18.dp), +): Modifier { + val isDarkTheme = isSystemInDarkTheme() + return modifier + .clip(shape) + .then( + if (isDarkTheme) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + shape, + ) + } else { + Modifier + }, + ) +} + @Composable private fun OverlayPopupMenuContainer(content: @Composable () -> Unit) { val isDarkTheme = isSystemInDarkTheme() @@ -1970,7 +1992,7 @@ private fun HomeScreen( verticalArrangement = Arrangement.spacedBy(6.dp), ) { item { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("模块状态", style = MaterialTheme.typography.titleMedium) val statusText = when (uiState.moduleActive) { @@ -1998,7 +2020,7 @@ private fun HomeScreen( MiuixSmallTitle(text = "注意事项") } item { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -2801,7 +2823,7 @@ private fun SectionTitle(title: String) { @Composable private fun SettingsGroupCard(content: @Composable () -> Unit) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))) { content() } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt index e9fd8beb..e97001a9 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt @@ -1,6 +1,7 @@ package io.github.hyperisland.ui.ai import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -50,6 +52,27 @@ private const val DEFAULT_AI_TIMEOUT = 3 private const val DEFAULT_AI_TEMPERATURE = 0.1 private const val DEFAULT_AI_MAX_TOKENS = 50 +@Composable +private fun aiCardModifier( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(18.dp), +): Modifier { + val isDarkTheme = isSystemInDarkTheme() + return modifier + .clip(shape) + .then( + if (isDarkTheme) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + shape, + ) + } else { + Modifier + }, + ) +} + @Composable fun AiConfigScreen( state: AiConfigState, @@ -81,7 +104,7 @@ fun AiConfigScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text("AI 增强", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Row( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -102,7 +125,7 @@ fun AiConfigScreen( if (state.enabled) { Text("API 参数", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { MiuixTextField( value = state.url, @@ -213,7 +236,7 @@ fun AiConfigScreen( } } - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 30dcfb85..2938dbf4 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -85,6 +85,27 @@ import top.yukonga.miuix.kmp.utils.pressable import top.yukonga.miuix.kmp.utils.scrollEndHaptic import kotlinx.coroutines.delay +@Composable +private fun sectionCardModifier( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(18.dp), +): Modifier { + val isDarkTheme = isSystemInDarkTheme() + return modifier + .clip(shape) + .then( + if (isDarkTheme) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + shape = shape, + ) + } else { + Modifier + }, + ) +} + @Composable fun AppsScreen( state: AppsUiState, @@ -367,7 +388,7 @@ fun AppChannelsScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Row( modifier = Modifier .fillMaxWidth() @@ -426,7 +447,7 @@ fun AppChannelsScreen( if (!state.appEnabled) { item { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Text( text = "请先开启应用总开关后再配置通知渠道", modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), @@ -457,7 +478,7 @@ fun AppChannelsScreen( } } else { item { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth()) { channels.forEach { channel -> val enabled = state.enabledChannels.isEmpty() || state.enabledChannels.contains(channel.id) @@ -831,24 +852,7 @@ private fun ChannelSectionTitle(title: String) { @Composable private fun ChannelSectionCard(content: @Composable () -> Unit) { - val isDarkTheme = isSystemInDarkTheme() - val cardShape = RoundedCornerShape(18.dp) - MiuixCard( - modifier = Modifier - .fillMaxWidth() - .clip(cardShape) - .then( - if (isDarkTheme) { - Modifier.border( - width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), - shape = cardShape, - ) - } else { - Modifier - }, - ), - ) { + MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -1233,11 +1237,12 @@ private fun AppItemRow( selected: Boolean, onSelectedChange: (Boolean) -> Unit, ) { + val cardShape = RoundedCornerShape(18.dp) MiuixCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) - .clip(RoundedCornerShape(18.dp)) + .then(sectionCardModifier(shape = cardShape)) .pressable(interactionSource = remember { MutableInteractionSource() }) .clickable { onClick() }, ) { diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt index 96b98d02..2d102f6f 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt @@ -1,8 +1,10 @@ package io.github.hyperisland.ui.blacklist import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,6 +44,27 @@ import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.pressable import top.yukonga.miuix.kmp.utils.scrollEndHaptic +@Composable +private fun blacklistCardModifier( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(18.dp), +): Modifier { + val isDarkTheme = isSystemInDarkTheme() + return modifier + .clip(shape) + .then( + if (isDarkTheme) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + shape, + ) + } else { + Modifier + }, + ) +} + @Composable fun BlacklistScreen( state: BlacklistUiState, @@ -143,7 +166,7 @@ private fun EmptyBlacklistState( query: String, onClearQuery: () -> Unit, ) { - MiuixCard(modifier = Modifier.fillMaxWidth()) { + MiuixCard(modifier = blacklistCardModifier(Modifier.fillMaxWidth())) { Column( modifier = Modifier .fillMaxWidth() @@ -178,7 +201,7 @@ private fun BlacklistAppRow( MiuixCard( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) + .then(blacklistCardModifier()) .pressable(interactionSource = remember { MutableInteractionSource() }) .clickable { onEnabledChange(!enabled) }, ) { From 038879e80ee4c8ac9f221bb102916bd6a20f27e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 12:21:06 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E9=80=82=E9=85=8D=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 9 +- .../hyperisland/ui/ComposeMainActivity.kt | 2093 +++++++++-------- .../kotlin/io/github/hyperisland/ui/FaIcon.kt | 4 +- .../hyperisland/ui/ai/AiConfigScreen.kt | 105 +- .../github/hyperisland/ui/app/AppsScreens.kt | 54 +- .../ui/blacklist/BlacklistScreen.kt | 8 +- lib/pages/main_page.dart | 87 +- 7 files changed, 1291 insertions(+), 1069 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c50b5de1..b2c6074d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -95,8 +95,8 @@ flutter { configurations.all { resolutionStrategy { - force("androidx.core:core:1.15.0") - force("androidx.core:core-ktx:1.15.0") + force("androidx.core:core:1.18.0") + force("androidx.core:core-ktx:1.18.0") } } @@ -111,12 +111,12 @@ dependencies { implementation(composeBom) androidTestImplementation(composeBom) - implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.activity:activity-compose:1.13.0") implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") - implementation("androidx.navigation:navigation-compose:2.9.0") + implementation("androidx.navigation3:navigation3-runtime-android:1.1.0-rc01") implementation("androidx.navigationevent:navigationevent-compose:1.0.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") @@ -128,6 +128,7 @@ dependencies { implementation("top.yukonga.miuix.kmp:miuix-preference-android:0.9.0") implementation("top.yukonga.miuix.kmp:miuix-icons-android:0.9.0") implementation("top.yukonga.miuix.kmp:miuix-blur-android:0.9.0") + implementation("top.yukonga.miuix.kmp:miuix-navigation3-ui-android:0.9.0") implementation("io.github.d4viddf:hyperisland_kit:0.4.3") compileOnly("io.github.libxposed:api:101.0.0") diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index 6f00b25c..0bdaea4f 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -8,16 +8,17 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import android.view.View import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate +import androidx.activity.SystemBarStyle import androidx.compose.foundation.clickable import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -26,6 +27,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -50,10 +52,6 @@ import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.Image -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -61,6 +59,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -72,16 +72,22 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme as MaterialDarkColorScheme +import androidx.compose.material3.lightColorScheme as MaterialLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import kotlinx.coroutines.launch import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.animation.core.animateDpAsState import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.Color @@ -117,18 +123,18 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.ui.NavDisplay import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat import io.github.hyperisland.data.prefs.PrefKeys import io.github.hyperisland.data.prefs.SettingsState import io.github.hyperisland.ui.ai.AiConfigScreen @@ -171,6 +177,7 @@ import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar +import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.rememberTopAppBarState import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet @@ -178,20 +185,28 @@ import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference as MiuixOverlayDropdownPreference import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.darkColorScheme as MiuixDarkColorScheme +import top.yukonga.miuix.kmp.theme.lightColorScheme as MiuixLightColorScheme import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.pressable import top.yukonga.miuix.kmp.utils.scrollEndHaptic import kotlinx.coroutines.delay import kotlin.math.cos import kotlin.math.sin +import java.lang.reflect.Method import top.yukonga.miuix.kmp.blur.LayerBackdrop +import top.yukonga.miuix.kmp.blur.BlurColors import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop import top.yukonga.miuix.kmp.blur.textureBlur import androidx.compose.runtime.compositionLocalOf val LocalContentPadding = compositionLocalOf { PaddingValues(0.dp) } +private var forcedAppDarkMode: Boolean? by mutableStateOf(null) + +@Composable +fun isAppInDarkTheme(): Boolean = forcedAppDarkMode ?: isSystemInDarkTheme() class ComposeMainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -211,8 +226,16 @@ class ComposeMainActivity : ComponentActivity() { window.isNavigationBarContrastEnforced = false } setContent { - MiuixTheme { - MaterialTheme { + val darkTheme = isAppInDarkTheme() + SideEffect { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController.isAppearanceLightStatusBars = !darkTheme + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + insetsController.isAppearanceLightNavigationBars = !darkTheme + } + } + MiuixTheme(colors = if (darkTheme) MiuixDarkColorScheme() else MiuixLightColorScheme()) { + MaterialTheme(colorScheme = if (darkTheme) MaterialDarkColorScheme() else MaterialLightColorScheme()) { HyperIslandComposeApp() } } @@ -226,6 +249,50 @@ private data class TopLevelDestination( val icon: androidx.compose.ui.graphics.vector.ImageVector, ) +private sealed interface AppScreen : NavKey { + data object Home : AppScreen + data object Apps : AppScreen + data object Settings : AppScreen + data class AppChannels(val packageName: String) : AppScreen + data class ChannelSettings( + val packageName: String, + val channelId: String, + val channelName: String, + ) : AppScreen + data object Blacklist : AppScreen + data object AiConfig : AppScreen +} + +private fun topLevelScreen(screen: AppScreen): AppScreen = when (screen) { + AppScreen.Home -> AppScreen.Home + AppScreen.Apps -> AppScreen.Apps + AppScreen.Settings -> AppScreen.Settings + is AppScreen.AppChannels -> AppScreen.Apps + is AppScreen.ChannelSettings -> AppScreen.Apps + AppScreen.Blacklist -> AppScreen.Settings + AppScreen.AiConfig -> AppScreen.Settings +} + +private fun screenTitle(screen: AppScreen): String = when (screen) { + AppScreen.Home -> "主页" + AppScreen.Apps -> "应用" + AppScreen.Settings -> "设置" + is AppScreen.AppChannels -> "渠道设置" + is AppScreen.ChannelSettings -> "渠道详情" + AppScreen.Blacklist -> "通知黑名单" + AppScreen.AiConfig -> "AI 配置" +} + +private fun screenDepth(screen: AppScreen): Int = when (screen) { + AppScreen.Home, + AppScreen.Apps, + AppScreen.Settings -> 1 + is AppScreen.AppChannels, + AppScreen.Blacklist, + AppScreen.AiConfig -> 2 + is AppScreen.ChannelSettings -> 3 +} + private data class NavigationStyleState( val floating: Boolean, val floatingMode: MiuixFloatingNavigationBarDisplayMode, @@ -338,88 +405,31 @@ private val SettingsFilledIcon: ImageVector by lazy { }.build() } -private fun mainRouteIndex(route: String?): Int = when (route) { - "home" -> 0 - "apps" -> 1 - "settings" -> 2 - else -> -1 -} - -private fun routeLevel(route: String?): Int = when { - route == "home" || route == "apps" || route == "settings" -> 1 - route == "blacklist" || route == "ai_config" || route?.startsWith("app_channels/") == true -> 2 - route?.startsWith("channel_settings/") == true -> 3 - else -> 1 -} - -private fun resolveMainSwitchDirection( - fromRoute: String?, - toRoute: String?, -): AnimatedContentTransitionScope.SlideDirection? { - val fromMain = mainRouteIndex(fromRoute) - val toMain = mainRouteIndex(toRoute) - if (fromMain < 0 || toMain < 0 || fromMain == toMain) return null - return if (toMain > fromMain) { - AnimatedContentTransitionScope.SlideDirection.Left - } else { - AnimatedContentTransitionScope.SlideDirection.Right - } -} - -private fun resolveRouteForwardSlideDirection( - fromRoute: String?, - toRoute: String?, -): AnimatedContentTransitionScope.SlideDirection? { - if (fromRoute == toRoute) return null - - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - - return if (toLevel > fromLevel) { - AnimatedContentTransitionScope.SlideDirection.Left - } else if (toLevel < fromLevel) { - AnimatedContentTransitionScope.SlideDirection.Right - } else { - AnimatedContentTransitionScope.SlideDirection.Left - } -} - -private fun resolveRoutePopSlideDirection( - fromRoute: String?, - toRoute: String?, -): AnimatedContentTransitionScope.SlideDirection? { - if (fromRoute == toRoute) return null - - val fromMain = mainRouteIndex(fromRoute) - val toMain = mainRouteIndex(toRoute) - if (fromMain >= 0 && toMain >= 0 && fromMain != toMain) { - return null - } - - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - return if (toLevel < fromLevel) { - AnimatedContentTransitionScope.SlideDirection.Right - } else if (toLevel > fromLevel) { - AnimatedContentTransitionScope.SlideDirection.Left - } else { - AnimatedContentTransitionScope.SlideDirection.Right - } -} - private const val DOCUMENTATION_URL = "https://hyperisland.1812z.top/" private const val GITHUB_REPO_URL = "https://github.com/1812z/HyperIsland" private const val GITHUB_RELEASE_URL = "https://github.com/1812z/HyperIsland/releases/latest" private const val QQ_GROUP_NUMBER = "1045114341" private const val DEFAULT_MARQUEE_SPEED = 100 private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 600 -private const val ROUTE_TRANSITION_DURATION_MS = 280 -private const val OVERLAY_TRANSITION_DURATION_MS = 320 +private const val BAR_BLUR_RADIUS = 28f +private const val BAR_BLUR_NOISE = 0.016f -private fun resolveNightMode(themeMode: String): Int = when (themeMode) { - "light" -> AppCompatDelegate.MODE_NIGHT_NO - "dark" -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM +@Composable +private fun barBlurColors(): BlurColors { + val dark = isAppInDarkTheme() + return if (dark) { + BlurColors( + brightness = -0.015f, + contrast = 0.86f, + saturation = 0.74f, + ) + } else { + BlurColors( + brightness = 0.008f, + contrast = 0.88f, + saturation = 0.78f, + ) + } } @Composable @@ -479,8 +489,11 @@ private fun HyperCeilerNavigationSwitchBar( modifier: Modifier = Modifier, backdrop: LayerBackdrop? = null, ) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() val colorScheme = MaterialTheme.colorScheme + val blurColors = barBlurColors() + val blurBackdrop = backdrop + val useBackdropBlur = blurBackdrop != null && !isDarkTheme val navBottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() AnimatedContent( targetState = style.floating, @@ -522,7 +535,7 @@ private fun HyperCeilerNavigationSwitchBar( ), contentAlignment = Alignment.BottomCenter, ) { - Row( + Box( modifier = Modifier .width(style.floatingContainerWidth) .height(style.floatingContainerHeight) @@ -534,24 +547,6 @@ private fun HyperCeilerNavigationSwitchBar( clip = false, ) .clip(androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius)) - .let { if (backdrop != null) it.textureBlur(backdrop, shape = androidx.compose.foundation.shape.RoundedCornerShape(style.floatingCornerRadius), blurRadiusX = 32f, blurRadiusY = 32f) else it } - .background( - brush = Brush.verticalGradient( - colors = if (isDarkTheme) { - listOf( - colorScheme.surface.copy(alpha = if (backdrop != null) 0.78f else 0.96f), - colorScheme.surfaceVariant.copy(alpha = if (backdrop != null) 0.70f else 0.90f), - colorScheme.surface.copy(alpha = if (backdrop != null) 0.66f else 0.86f), - ) - } else { - listOf( - Color(0xFFFFFFFF).copy(alpha = if (backdrop != null) 0.65f else 1f), - Color(0xFFFAFAFB).copy(alpha = if (backdrop != null) 0.65f else 1f), - Color(0xFFF3F4F6).copy(alpha = if (backdrop != null) 0.65f else 1f), - ) - }, - ), - ) .drawWithCache { val halfW = size.width / 2f val halfH = size.height / 2f @@ -565,10 +560,10 @@ private fun HyperCeilerNavigationSwitchBar( val outerStrokeBrush = if (isDarkTheme) { Brush.linearGradient( colors = listOf( - colorScheme.outline.copy(alpha = 0.44f), - colorScheme.onSurface.copy(alpha = 0.22f), - colorScheme.surfaceVariant.copy(alpha = 0.26f), - colorScheme.outline.copy(alpha = 0.34f), + colorScheme.outline.copy(alpha = 0.28f), + colorScheme.onSurface.copy(alpha = 0.14f), + colorScheme.surfaceVariant.copy(alpha = 0.16f), + colorScheme.outline.copy(alpha = 0.22f), ), start = Offset(halfW - dx, halfH - dy), end = Offset(halfW + dx, halfH + dy), @@ -588,8 +583,8 @@ private fun HyperCeilerNavigationSwitchBar( val innerStrokeBrush = if (isDarkTheme) { Brush.verticalGradient( colors = listOf( - colorScheme.onSurface.copy(alpha = 0.20f), - colorScheme.outline.copy(alpha = 0.10f), + colorScheme.onSurface.copy(alpha = 0.12f), + colorScheme.outline.copy(alpha = 0.06f), Color.Transparent, ), startY = 0f, @@ -622,52 +617,70 @@ private fun HyperCeilerNavigationSwitchBar( ) } }, - verticalAlignment = Alignment.CenterVertically, ) { - items.forEachIndexed { index, destination -> - HyperCeilerNavItem( - destination = destination, - selected = index == selectedIndex, - showLabel = style.floatingMode != MiuixFloatingNavigationBarDisplayMode.IconOnly, - iconSize = style.floatingIconSize, - itemHorizontalPadding = style.floatingItemHorizontalPadding, - unselectedAlpha = style.unselectedAlpha, - suppressPressEffect = true, - onClick = { onDestinationClick(destination) }, - modifier = Modifier.weight(1f), - ) + MiuiBlurredSurface( + modifier = Modifier.fillMaxSize(), + fallbackBackdrop = if (useBackdropBlur) blurBackdrop else null, + fallbackBlurColors = blurColors, + overlayAlphaFallbackDark = 0.48f, + overlayAlphaFallbackLight = 0.54f, + overlayAlphaNativeDark = 0.52f, + overlayAlphaNativeLight = 0.58f, + ) {} + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, destination -> + HyperCeilerNavItem( + destination = destination, + selected = index == selectedIndex, + showLabel = style.floatingMode != MiuixFloatingNavigationBarDisplayMode.IconOnly, + iconSize = style.floatingIconSize, + itemHorizontalPadding = style.floatingItemHorizontalPadding, + unselectedAlpha = style.unselectedAlpha, + suppressPressEffect = true, + onClick = { onDestinationClick(destination) }, + modifier = Modifier.weight(1f), + ) + } } } } } else { - Column( - modifier = modifier - .fillMaxWidth() - .let { if (backdrop != null) it.textureBlur(backdrop, shape = androidx.compose.ui.graphics.RectangleShape, blurRadiusX = 32f, blurRadiusY = 32f) else it } - .background(MaterialTheme.colorScheme.surface.copy(alpha = if (backdrop != null) 0.7f else 1f)) + MiuiBlurredSurface( + modifier = modifier.fillMaxWidth(), + fallbackBackdrop = if (useBackdropBlur) blurBackdrop else null, + fallbackBlurColors = blurColors, + overlayAlphaFallbackDark = 0.48f, + overlayAlphaFallbackLight = 0.54f, + overlayAlphaNativeDark = 0.52f, + overlayAlphaNativeLight = 0.58f, ) { - if (style.bottomShowDivider) { - HorizontalDivider() - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = if (style.bottomWindowInsetsPadding) navBottomInset else 0.dp) - .height(style.bottomContainerHeight), - verticalAlignment = Alignment.CenterVertically, - ) { - items.forEachIndexed { index, destination -> - HyperCeilerNavItem( - destination = destination, - selected = index == selectedIndex, - showLabel = style.bottomShowLabel, - iconSize = style.bottomIconSize, - itemHorizontalPadding = style.bottomItemHorizontalPadding, - unselectedAlpha = style.unselectedAlpha, - suppressPressEffect = false, - onClick = { onDestinationClick(destination) }, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.fillMaxWidth()) { + if (style.bottomShowDivider) { + HorizontalDivider() + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (style.bottomWindowInsetsPadding) navBottomInset else 0.dp) + .height(style.bottomContainerHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, destination -> + HyperCeilerNavItem( + destination = destination, + selected = index == selectedIndex, + showLabel = style.bottomShowLabel, + iconSize = style.bottomIconSize, + itemHorizontalPadding = style.bottomItemHorizontalPadding, + unselectedAlpha = style.unselectedAlpha, + suppressPressEffect = false, + onClick = { onDestinationClick(destination) }, + modifier = Modifier.weight(1f), + ) + } } } } @@ -675,31 +688,19 @@ private fun HyperCeilerNavigationSwitchBar( } } -private fun routeTitle(route: String?): String { - return when { - route == "home" -> "主页" - route == "apps" -> "应用" - route == "settings" -> "设置" - route?.startsWith("app_channels/") == true -> "渠道设置" - route == "blacklist" -> "通知黑名单" - route == "ai_config" -> "AI 配置" - else -> "HyperIsland" - } -} - @Composable private fun primaryCardModifier( modifier: Modifier = Modifier, shape: RoundedCornerShape = RoundedCornerShape(18.dp), ): Modifier { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() return modifier .clip(shape) .then( if (isDarkTheme) { Modifier.border( 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + MaterialTheme.colorScheme.outline.copy(alpha = 0.22f), shape, ) } else { @@ -710,7 +711,7 @@ private fun primaryCardModifier( @Composable private fun OverlayPopupMenuContainer(content: @Composable () -> Unit) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() val containerShape = RoundedCornerShape(16.dp) Box( modifier = Modifier @@ -747,21 +748,197 @@ private fun openExternalUrl(context: Context, url: String) { } } +private fun isBlurEffectEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false + return runCatching { + val wm = context.getSystemService(WindowManager::class.java) + wm?.isCrossWindowBlurEnabled == true + }.getOrDefault(false) +} + +@Composable +private fun MiuiBlurredSurface( + modifier: Modifier = Modifier, + fallbackBackdrop: LayerBackdrop? = null, + fallbackBlurColors: BlurColors = BlurColors(), + overlayAlphaFallbackDark: Float = 0.48f, + overlayAlphaFallbackLight: Float = 0.54f, + overlayAlphaNativeDark: Float = 0.52f, + overlayAlphaNativeLight: Float = 0.58f, + content: @Composable BoxScope.() -> Unit, +) { + val context = LocalContext.current + val blurRadiusPx = with(LocalDensity.current) { 46.dp.roundToPx() } + var nativeBlurApplied by remember { mutableStateOf(false) } + val useFallbackBlur = fallbackBackdrop != null && !nativeBlurApplied + val overlayAlpha = if (useFallbackBlur) { + if (isAppInDarkTheme()) overlayAlphaFallbackDark else overlayAlphaFallbackLight + } else { + if (isAppInDarkTheme()) overlayAlphaNativeDark else overlayAlphaNativeLight + } + Box(modifier = modifier) { + AndroidView( + factory = { ctx -> + View(ctx).apply { + isClickable = false + isFocusable = false + setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + }, + modifier = Modifier.matchParentSize(), + update = { view -> + nativeBlurApplied = MiuiTopBarBlurCompat.apply(view, context, blurRadiusPx) + }, + ) + Box( + modifier = Modifier + .matchParentSize() + .let { + if (useFallbackBlur) { + it.textureBlur( + backdrop = fallbackBackdrop, + shape = RectangleShape, + blurRadiusX = BAR_BLUR_RADIUS, + blurRadiusY = BAR_BLUR_RADIUS, + noiseCoefficient = BAR_BLUR_NOISE, + colors = fallbackBlurColors, + ) + } else { + it + } + }, + ) + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = overlayAlpha)), + ) + content() + } +} + +@Composable +private fun MiuiBlurredTopBar( + modifier: Modifier = Modifier, + fallbackBackdrop: LayerBackdrop? = null, + fallbackBlurColors: BlurColors = BlurColors(), + content: @Composable BoxScope.() -> Unit, +) { + MiuiBlurredSurface( + modifier = modifier, + fallbackBackdrop = fallbackBackdrop, + fallbackBlurColors = fallbackBlurColors, + content = content, + ) +} + +private object MiuiTopBarBlurCompat { + private const val TAG = "MiuiTopBarBlur" + private var loggedOnce = false + + private val fanBlurClass: Class<*>? by lazy { + runCatching { Class.forName("fan.core.utils.MiuiBlurUtils") }.getOrNull() + } + private val fanIsEnable: Method? by lazy { + runCatching { fanBlurClass?.getDeclaredMethod("isEnable") }.getOrNull() + } + private val fanIsEffectEnable: Method? by lazy { + runCatching { fanBlurClass?.getDeclaredMethod("isEffectEnable", Context::class.java) }.getOrNull() + } + private val fanSetBackgroundBlur: Method? by lazy { + runCatching { + fanBlurClass?.getDeclaredMethod("setBackgroundBlur", View::class.java, Int::class.javaPrimitiveType) + }.getOrNull() + } + private val fanSetViewBlurMode: Method? by lazy { + runCatching { + fanBlurClass?.getDeclaredMethod("setViewBlurMode", View::class.java, Int::class.javaPrimitiveType) + }.getOrNull() + } + + private val setBackgroundBlur: Method? by lazy { + runCatching { View::class.java.getDeclaredMethod("setBackgroundBlur", Int::class.javaPrimitiveType) }.getOrNull() + } + private val setViewBlurMode: Method? by lazy { + runCatching { View::class.java.getDeclaredMethod("setViewBlurMode", Int::class.javaPrimitiveType) }.getOrNull() + } + private val setPassWindowBlurEnabled: Method? by lazy { + runCatching { + View::class.java.getDeclaredMethod("setPassWindowBlurEnabled", Boolean::class.javaPrimitiveType) + }.getOrNull() + } + private val setMiBackgroundBlurMode: Method? by lazy { + runCatching { View::class.java.getDeclaredMethod("setMiBackgroundBlurMode", Int::class.javaPrimitiveType) }.getOrNull() + } + private val setMiBackgroundBlurRadius: Method? by lazy { + runCatching { View::class.java.getDeclaredMethod("setMiBackgroundBlurRadius", Int::class.javaPrimitiveType) }.getOrNull() + } + private val setMiViewBlurMode: Method? by lazy { + runCatching { View::class.java.getDeclaredMethod("setMiViewBlurMode", Int::class.javaPrimitiveType) }.getOrNull() + } + + fun apply(view: View, context: Context, radiusPx: Int): Boolean { + return runCatching { + var applied = false + val safeRadius = radiusPx.coerceIn(1, 500) + + val fanEnabled = runCatching { fanIsEnable?.invoke(null) as? Boolean }.getOrNull() ?: false + val fanEffectEnabled = runCatching { fanIsEffectEnable?.invoke(null, context) as? Boolean }.getOrNull() ?: false + if (fanEnabled && fanEffectEnabled && fanSetBackgroundBlur != null && fanSetViewBlurMode != null) { + fanSetBackgroundBlur?.invoke(null, view, safeRadius) + fanSetViewBlurMode?.invoke(null, view, 0) + applied = true + } + + if (!applied) { + setPassWindowBlurEnabled?.invoke(view, true) + setMiBackgroundBlurMode?.invoke(view, 1) + setMiBackgroundBlurRadius?.invoke(view, safeRadius) + setMiViewBlurMode?.invoke(view, 1) + applied = setMiBackgroundBlurMode != null && setMiBackgroundBlurRadius != null + } + + if (!applied && setBackgroundBlur != null && setViewBlurMode != null) { + setBackgroundBlur?.invoke(view, safeRadius) + setViewBlurMode?.invoke(view, 0) + applied = true + } + + if (!loggedOnce) { + loggedOnce = true + Log.i( + TAG, + "applied=$applied, fanClass=${fanBlurClass != null}, fanEnabled=$fanEnabled, fanEffectEnabled=$fanEffectEnabled", + ) + } + applied + }.getOrDefault(false) + } +} + @Composable private fun HyperIslandComposeApp() { - val navController = rememberNavController() + val backStack = remember { mutableStateListOf(AppScreen.Home) } val navigationEventDispatcherOwner = rememberNavigationEventDispatcherOwner(parent = null) val backdrop = rememberLayerBackdrop() val context = LocalContext.current + val blurEnabled = remember(context) { isBlurEffectEnabled(context) } + val activeBackdrop = if (blurEnabled) backdrop else null + val topBarFallbackBlurColors = barBlurColors() val homeVm: HomeViewModel = viewModel() val appsVm: AppsViewModel = viewModel() val blacklistVm: BlacklistViewModel = viewModel() val settingsVm: SettingsViewModel = viewModel() val settingsState by settingsVm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(settingsState.themeMode) { - val targetMode = resolveNightMode(settingsState.themeMode) - if (AppCompatDelegate.getDefaultNightMode() != targetMode) { - AppCompatDelegate.setDefaultNightMode(targetMode) + var useFloatingNavigationBarUi by remember { mutableStateOf(settingsState.useFloatingNavigationBar) } + LaunchedEffect(settingsState.useFloatingNavigationBar) { + useFloatingNavigationBarUi = settingsState.useFloatingNavigationBar + } + SideEffect { + forcedAppDarkMode = when (settingsState.themeMode) { + "dark" -> true + "light" -> false + else -> null } } val appsState by appsVm.uiState.collectAsStateWithLifecycle() @@ -836,7 +1013,7 @@ private fun HyperIslandComposeApp() { unselectedAlpha = 0.4f, ) } - val activeNavStyleState = if (settingsState.useFloatingNavigationBar) { + val activeNavStyleState = if (useFloatingNavigationBarUi) { capsuleNavStyleState } else { bottomNavStyleState @@ -888,32 +1065,72 @@ private fun HyperIslandComposeApp() { } } - val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route + val currentScreen = backStack.lastOrNull() ?: AppScreen.Home + val currentTopLevelScreen = topLevelScreen(currentScreen) + fun topLevelIndexOf(screen: AppScreen): Int = when (topLevelScreen(screen)) { + AppScreen.Home -> 0 + AppScreen.Apps -> 1 + else -> 2 + } + fun topLevelScreenOf(index: Int): AppScreen = when (index) { + 1 -> AppScreen.Apps + 2 -> AppScreen.Settings + else -> AppScreen.Home + } + var selectedTopLevelIndex by rememberSaveable { mutableIntStateOf(topLevelIndexOf(currentTopLevelScreen)) } + val isAppChannelsScreen = currentScreen is AppScreen.AppChannels + val isChannelSettingsScreen = currentScreen is AppScreen.ChannelSettings + val isBlacklistScreen = currentScreen == AppScreen.Blacklist + val isAiConfigScreen = currentScreen == AppScreen.AiConfig + val isAppsPrimaryScreen = currentScreen == AppScreen.Apps val appListState = rememberLazyListState() - - val scope = rememberCoroutineScope() - val selectedIndex = items.indexOfFirst { it.route == currentRoute }.coerceAtLeast(0) - val onPrimaryDestinationClick: (TopLevelDestination) -> Unit = { destination -> - navController.navigate(destination.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true + fun navigateTo(screen: AppScreen) { + if (backStack.lastOrNull() != screen) { + backStack.add(screen) + } + } + fun popScreen(): Boolean { + return if (backStack.size > 1) { + backStack.removeLast() + true + } else { + false } } - val topBarTitle = if (currentRoute?.startsWith("channel_settings/") == true) { - Uri.decode(backStackEntry?.arguments?.getString("channelName").orEmpty()) - .ifBlank { "渠道详情" } + fun navigateTopLevel(screen: AppScreen) { + val target = topLevelScreen(screen) + val latest = backStack.lastOrNull() ?: AppScreen.Home + if (latest == target && backStack.size == 1) return + if (backStack.size == 1) { + // 一级页面切换使用同层替换,避免被识别为 push/pop 导致动画方向异常。 + backStack[0] = target + return + } + backStack.clear() + backStack.add(target) + } + val selectedIndex = selectedTopLevelIndex + val onPrimaryDestinationClick: (TopLevelDestination) -> Unit = { destination -> + val targetIndex = items.indexOfFirst { it.route == destination.route }.let { if (it == -1) 0 else it } + selectedTopLevelIndex = targetIndex + val target = topLevelScreenOf(targetIndex) + navigateTopLevel(target) + } + val topBarTitle = if (currentScreen is AppScreen.ChannelSettings) { + currentScreen.channelName.ifBlank { "渠道详情" } } else { - routeTitle(currentRoute) + screenTitle(currentScreen) + } + val isSecondaryRoute = isAppChannelsScreen || isChannelSettingsScreen || isBlacklistScreen || isAiConfigScreen + LaunchedEffect(currentTopLevelScreen, isSecondaryRoute) { + if (!isSecondaryRoute) { + val latestIndex = topLevelIndexOf(currentTopLevelScreen) + if (selectedTopLevelIndex != latestIndex) { + selectedTopLevelIndex = latestIndex + } + } } - val isSecondaryRoute = currentRoute?.startsWith("app_channels/") == true || - currentRoute?.startsWith("channel_settings/") == true || - currentRoute == "blacklist" || - currentRoute == "ai_config" val secondaryTopBarStyleState = remember { TopBarStyleState( variant = TopBarVariant.Secondary, @@ -940,8 +1157,8 @@ private fun HyperIslandComposeApp() { } val activeTopBarStyleState = when { isSecondaryRoute -> secondaryTopBarStyleState - currentRoute == "apps" -> appsTopBarStyleState - currentRoute == "settings" -> settingsTopBarStyleState + currentScreen == AppScreen.Apps -> appsTopBarStyleState + currentScreen == AppScreen.Settings -> settingsTopBarStyleState else -> homeTopBarStyleState } fun dismissTransientUi(): Boolean { @@ -979,6 +1196,18 @@ private fun HyperIslandComposeApp() { else -> false } } + fun handleNavigationBack(): Boolean { + if (dismissTransientUi()) return true + if (popScreen()) return true + return if (currentScreen != AppScreen.Home) { + backStack.clear() + backStack.add(AppScreen.Home) + true + } else { + (context as? ComponentActivity)?.finish() + true + } + } val shouldHandleBack = showAppsMenu || showAppChannelsMenu || showBlacklistMenu || showRestartDialog || showSponsorDialog || isAppsSearchExpanded || isBlacklistSearchExpanded val homeScrollBehavior = MiuixScrollBehavior( state = rememberTopAppBarState(), @@ -993,275 +1222,393 @@ private fun HyperIslandComposeApp() { canScroll = { !popupShowing }, ) val topBarOwnerRoute = when { - currentRoute?.startsWith("app_channels/") == true || - currentRoute?.startsWith("channel_settings/") == true -> "apps" - currentRoute == "blacklist" || currentRoute == "ai_config" -> "settings" - else -> currentRoute + isAppChannelsScreen || isChannelSettingsScreen -> "apps" + isBlacklistScreen || isAiConfigScreen -> "settings" + currentScreen == AppScreen.Apps -> "apps" + currentScreen == AppScreen.Settings -> "settings" + else -> "home" } val activeTopBarScrollBehavior = when (topBarOwnerRoute) { "apps" -> appsScrollBehavior "settings" -> settingsScrollBehavior else -> homeScrollBehavior } - val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - - BackHandler(enabled = shouldHandleBack) { - dismissTransientUi() + BackHandler { + if (!dismissTransientUi()) { + handleNavigationBack() + } } - val layoutDirection = LocalLayoutDirection.current - val insets = WindowInsets.navigationBars.asPaddingValues() - val isAppsRoute = currentRoute == "apps" val topBarCollapseProgress by remember( - currentRoute, + currentScreen, activeTopBarScrollBehavior.state, ) { derivedStateOf { activeTopBarScrollBehavior.state.collapsedFraction.coerceIn(0f, 1f) } } - val isAppsLargeTitleExpanded by remember(currentRoute, appsScrollBehavior.state) { + val isAppsLargeTitleExpanded by remember(currentScreen, appsScrollBehavior.state) { derivedStateOf { - currentRoute == "apps" && appsScrollBehavior.state.collapsedFraction < 0.98f + isAppsPrimaryScreen && appsScrollBehavior.state.collapsedFraction < 0.98f } } - - CompositionLocalProvider( - LocalNavigationEventDispatcherOwner.provides(navigationEventDispatcherOwner), - ) { - MiuixScaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - val topBarScrollBehavior = activeTopBarScrollBehavior - - val isAppsRoute = currentRoute == "apps" - val isBlacklistRoute = currentRoute == "blacklist" - val showTopBarExtraContent = - (isAppsRoute && (appsSelectionMode || isAppsSearchExpanded)) || - isBlacklistRoute - val searchExpandTransitionMs = 260 - val collapseProgress = topBarCollapseProgress - val baseExpandedAlpha = when { - isAppsRoute && showTopBarExtraContent -> 0.72f - activeTopBarStyleState.variant == TopBarVariant.PrimaryHome -> 0.62f - else -> 0.68f + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + settingsVm.importConfigFromUri(uri) + } + } + val topLevelSaveableStateHolder = rememberSaveableStateHolder() + val selectedIndexInBar = topLevelIndexOf(currentScreen) + + LaunchedEffect(Unit) { + homeVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(Unit) { + appsVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(Unit) { + settingsVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(Unit) { + blacklistVm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + + @Composable + fun SceneContent(scene: AppScreen, innerPadding: PaddingValues) { + CompositionLocalProvider(LocalContentPadding provides innerPadding) { + when (scene) { + AppScreen.Home -> { + val uiState by homeVm.uiState.collectAsStateWithLifecycle() + HomeScreen( + uiState = uiState, + onRefresh = homeVm::refreshStatus, + onSendTest = homeVm::sendTest, + modifier = Modifier.nestedScroll(homeScrollBehavior.nestedScrollConnection), + ) } - val baseCollapsedAlpha = when { - activeTopBarStyleState.variant == TopBarVariant.Secondary -> 0.88f - isAppsRoute && showTopBarExtraContent -> 0.9f - else -> 0.86f + AppScreen.Apps -> { + AppsScreen( + state = appsState, + onRefresh = appsVm::refresh, + onQueryChange = appsVm::setQuery, + onAppEnabledChange = appsVm::setEnabled, + onAppSelectedChange = appsVm::toggleSelectedPackage, + onSelectAll = appsVm::setSelectedPackages, + onOpenAppChannels = { pkg -> navigateTo(AppScreen.AppChannels(pkg)) }, + onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, + onBatchApplySelected = appsVm::batchApplyToSelectedApps, + onSelectionModeChanged = { appsSelectionMode = it }, + appListState = appListState, + selectionRequestId = appsSelectionRequestId, + exitSelectionRequestId = appsExitSelectionRequestId, + enableSelectedRequestId = appsEnableSelectedRequestId, + disableSelectedRequestId = appsDisableSelectedRequestId, + selectEnabledRequestId = appsSelectEnabledRequestId, + batchSelectedRequestId = appsBatchSelectedRequestId, + enableAllRequestId = appsEnableAllRequestId, + disableAllRequestId = appsDisableAllRequestId, + batchRequestId = appsBatchRequestId, + topAppBarScrollBehavior = appsScrollBehavior, + canPullToRefresh = true, + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), + ) } - val topBarSurfaceAlpha = androidx.compose.ui.util.lerp( - baseExpandedAlpha, - baseCollapsedAlpha, - collapseProgress, - ) - val topBarBlurRadius = androidx.compose.ui.util.lerp( - 16f, - if (activeTopBarStyleState.variant == TopBarVariant.Secondary) 22f else 26f, - collapseProgress, - ) - val topBarBackgroundColor = lerp( - MaterialTheme.colorScheme.surface.copy(alpha = 0f), - MaterialTheme.colorScheme.surface.copy(alpha = topBarSurfaceAlpha), - collapseProgress, - ) - val shouldBlurTopBar = collapseProgress > 0.02f - - Box( - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .matchParentSize() - .let { - if (backdrop != null) { - it.textureBlur( - backdrop, - shape = RectangleShape, - blurRadiusX = topBarBlurRadius, - blurRadiusY = topBarBlurRadius, - ) - } else it + AppScreen.Settings -> { + SettingsScreen( + state = settingsState, + onToggle = { key, enabled -> + if (key == PrefKeys.USE_FLOATING_NAVIGATION_BAR) { + useFloatingNavigationBarUi = enabled } - .alpha(if (shouldBlurTopBar) 1f else 0f) - .background(topBarBackgroundColor) + settingsVm.updateSwitch(key, enabled) + }, + onMarqueeSpeed = settingsVm::updateMarqueeSpeed, + onBigIslandWidth = settingsVm::updateBigIslandMaxWidth, + onThemeModeChange = settingsVm::updateThemeMode, + onLocaleChange = settingsVm::updateLocale, + onHideDesktopIcon = settingsVm::setDesktopIconHidden, + onOpenBlacklist = { navigateTo(AppScreen.Blacklist) }, + onOpenAiConfig = { navigateTo(AppScreen.AiConfig) }, + onCheckUpdate = { openExternalUrl(context, GITHUB_RELEASE_URL) }, + onOpenGithub = { openExternalUrl(context, GITHUB_REPO_URL) }, + onExportToFile = settingsVm::exportConfigToFile, + onPickImportFile = { + importLauncher.launch(arrayOf("application/json", "text/plain")) + }, + onExportToClipboard = settingsVm::exportConfigToClipboard, + onImportFromClipboard = settingsVm::importConfigFromClipboard, + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), + ) + } + is AppScreen.AppChannels -> { + val vm: AppChannelsViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(scene.packageName) { + vm.setPackageNameIfEmpty(scene.packageName) + } + AppChannelsScreen( + state = state, + onRefresh = vm::refresh, + onSetAppEnabled = vm::setAppEnabled, + onToggleChannel = vm::toggleChannel, + onEnableAllChannels = vm::enableAllChannels, + onOpenChannelSettings = { channelId, channelName -> + navigateTo( + AppScreen.ChannelSettings( + packageName = scene.packageName, + channelId = channelId, + channelName = channelName, + ), + ) + }, + onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, + enableAllRequestId = appChannelsEnableAllRequestId, + batchRequestId = appChannelsBatchRequestId, + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), + ) + } + is AppScreen.ChannelSettings -> { + val vm: AppChannelsViewModel = viewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(scene.packageName) { + vm.setPackageNameIfEmpty(scene.packageName) + } + ChannelSettingsScreen( + state = state, + channelId = scene.channelId, + onRefresh = vm::refresh, + onSetTemplate = { vm.setTemplate(scene.channelId, it) }, + onSetTimeout = { vm.setTimeout(scene.channelId, it) }, + onSetSetting = { setting, value -> + vm.setSetting(scene.channelId, setting, value) + }, + onSetHighlightColor = { vm.setHighlightColor(scene.channelId, it) }, + modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), + ) + } + AppScreen.Blacklist -> { + BlacklistScreen( + state = blacklistState, + onRefresh = blacklistVm::refresh, + onQueryChange = blacklistVm::setQuery, + onSetBlacklisted = blacklistVm::setBlacklisted, + canPullToRefresh = false, + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), + ) + } + AppScreen.AiConfig -> { + val vm: AiConfigViewModel = viewModel() + val uiState by vm.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.events.collect { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + AiConfigScreen( + state = uiState, + onUpdate = vm::setState, + onSave = vm::save, + onTest = vm::testConnection, + modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), ) - Column(modifier = Modifier.fillMaxWidth()) { + } + } + } + } + + MiuixScaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuiBlurredTopBar( + modifier = Modifier.fillMaxWidth(), + fallbackBackdrop = activeBackdrop, + fallbackBlurColors = topBarFallbackBlurColors, + ) { + Column(modifier = Modifier.fillMaxWidth()) { MiuixTopAppBar( title = topBarTitle, + scrollBehavior = activeTopBarScrollBehavior, modifier = Modifier.fillMaxWidth(), color = Color.Transparent, + defaultWindowInsetsPadding = false, navigationIcon = { - when (activeTopBarStyleState.variant) { - TopBarVariant.Secondary -> { - MiuixIconButton(onClick = { - backDispatcher?.onBackPressed() ?: run { - val dismissed = dismissTransientUi() - if (!dismissed) { - navController.popBackStack() - } - } - }) { + when { + isSecondaryRoute -> { + MiuixIconButton(onClick = { handleNavigationBack() }) { Icon( imageVector = MiuixIcons.Basic.ArrowRight, contentDescription = "返回", modifier = Modifier.rotate(180f), + tint = MaterialTheme.colorScheme.onSurface, ) } } - - TopBarVariant.PrimaryApps -> { - if (appsSelectionMode) { - MiuixIconButton(onClick = { appsExitSelectionRequestId += 1 }) { - FaIcon( - glyph = FaGlyph.Times, - contentDescription = "退出多选", - ) - } - } else { - Spacer(modifier = Modifier.size(40.dp)) + currentScreen == AppScreen.Apps && appsSelectionMode -> { + MiuixIconButton(onClick = { appsExitSelectionRequestId += 1 }) { + FaIcon( + glyph = FaGlyph.Times, + contentDescription = "退出多选", + tint = MaterialTheme.colorScheme.onSurface, + ) } } - - else -> Spacer(modifier = Modifier.size(40.dp)) + else -> { + Spacer(modifier = Modifier.size(40.dp)) + } } }, actions = { - when (activeTopBarStyleState.variant) { - TopBarVariant.Secondary -> { - if (currentRoute?.startsWith("app_channels/") == true) { - Box { - BackHandler(enabled = showAppChannelsMenu) { - showAppChannelsMenu = false - } - MiuixIconButton(onClick = { showAppChannelsMenu = true }) { - Icon( - imageVector = MiuixIcons.Regular.MoreCircle, - contentDescription = "渠道页更多操作", - ) - } - OverlayListPopup( - show = showAppChannelsMenu, - alignment = MiuixPopupPositionProvider.Align.End, - onDismissRequest = { showAppChannelsMenu = false }, - onDismissFinished = {}, - ) { - val menuItems = listOf( - "启用全部渠道" to { - showAppChannelsMenu = false - appChannelsEnableAllRequestId += 1 - }, - "批量设置渠道配置" to { - showAppChannelsMenu = false - appChannelsBatchRequestId += 1 - }, - ) - OverlayPopupMenuContainer { - menuItems.forEachIndexed { index, (title, action) -> - MiuixDropdownImpl( - text = title, - optionSize = menuItems.size, - isSelected = false, - onSelectedIndexChange = { action() }, - index = index, - ) - } + when { + isSecondaryRoute -> { + when { + currentScreen is AppScreen.AppChannels || currentScreen is AppScreen.ChannelSettings -> { + Box { + BackHandler(enabled = showAppChannelsMenu) { + showAppChannelsMenu = false } - } - } - } else if (currentRoute == "blacklist") { - MiuixIconButton( - onClick = { - if (!isBlacklistSearchExpanded) { - blacklistSearchFieldValue = TextFieldValue( - text = blacklistState.query, - selection = TextRange(blacklistState.query.length), + MiuixIconButton(onClick = { showAppChannelsMenu = true }) { + Icon( + imageVector = MiuixIcons.Regular.MoreCircle, + contentDescription = "渠道页更多操作", + tint = MaterialTheme.colorScheme.onSurface, ) } - isBlacklistSearchExpanded = !isBlacklistSearchExpanded - }, - ) { - Icon( - imageVector = MiuixIcons.Regular.Search, - contentDescription = "搜索", - ) - } - Box { - BackHandler(enabled = showBlacklistMenu) { - showBlacklistMenu = false + OverlayListPopup( + show = showAppChannelsMenu, + alignment = MiuixPopupPositionProvider.Align.End, + onDismissRequest = { showAppChannelsMenu = false }, + onDismissFinished = {}, + ) { + val menuItems = listOf( + "启用全部渠道" to { + showAppChannelsMenu = false + appChannelsEnableAllRequestId += 1 + }, + "批量设置渠道配置" to { + showAppChannelsMenu = false + appChannelsBatchRequestId += 1 + }, + ) + OverlayPopupMenuContainer { + menuItems.forEachIndexed { index, (title, action) -> + MiuixDropdownImpl( + text = title, + optionSize = menuItems.size, + isSelected = false, + onSelectedIndexChange = { action() }, + index = index, + ) + } + } + } } - MiuixIconButton(onClick = { showBlacklistMenu = true }) { + } + currentScreen == AppScreen.Blacklist -> { + MiuixIconButton( + onClick = { + if (!isBlacklistSearchExpanded) { + blacklistSearchFieldValue = TextFieldValue( + text = blacklistState.query, + selection = TextRange(blacklistState.query.length), + ) + } + isBlacklistSearchExpanded = !isBlacklistSearchExpanded + }, + ) { Icon( - imageVector = MiuixIcons.Regular.MoreCircle, - contentDescription = "黑名单页更多操作", + imageVector = MiuixIcons.Regular.Search, + contentDescription = "搜索", + tint = MaterialTheme.colorScheme.onSurface, ) } - OverlayListPopup( - show = showBlacklistMenu, - alignment = MiuixPopupPositionProvider.Align.End, - onDismissRequest = { showBlacklistMenu = false }, - onDismissFinished = {}, - ) { - val menuItems = listOf( - "游戏预设" to { - showBlacklistMenu = false - blacklistVm.applyGamePreset() - }, - "全部加入" to { - showBlacklistMenu = false - blacklistVm.enableAllVisible() - }, - "全部移除" to { - showBlacklistMenu = false - blacklistVm.disableAllVisible() - }, - (if (blacklistState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { - showBlacklistMenu = false - blacklistVm.setShowSystemApps(!blacklistState.showSystemApps) - }, - "刷新" to { - showBlacklistMenu = false - blacklistVm.refresh() - }, - ) - OverlayPopupMenuContainer { - menuItems.forEachIndexed { index, (title, action) -> - MiuixDropdownImpl( - text = title, - optionSize = menuItems.size, - isSelected = false, - onSelectedIndexChange = { action() }, - index = index, - ) + Box { + BackHandler(enabled = showBlacklistMenu) { + showBlacklistMenu = false + } + MiuixIconButton(onClick = { showBlacklistMenu = true }) { + Icon( + imageVector = MiuixIcons.Regular.MoreCircle, + contentDescription = "黑名单页更多操作", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + OverlayListPopup( + show = showBlacklistMenu, + alignment = MiuixPopupPositionProvider.Align.End, + onDismissRequest = { showBlacklistMenu = false }, + onDismissFinished = {}, + ) { + val menuItems = listOf( + "游戏预设" to { + showBlacklistMenu = false + blacklistVm.applyGamePreset() + }, + "全部加入" to { + showBlacklistMenu = false + blacklistVm.enableAllVisible() + }, + "全部移除" to { + showBlacklistMenu = false + blacklistVm.disableAllVisible() + }, + (if (blacklistState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + showBlacklistMenu = false + blacklistVm.setShowSystemApps(!blacklistState.showSystemApps) + }, + "刷新" to { + showBlacklistMenu = false + blacklistVm.refresh() + }, + ) + OverlayPopupMenuContainer { + menuItems.forEachIndexed { index, (title, action) -> + MiuixDropdownImpl( + text = title, + optionSize = menuItems.size, + isSelected = false, + onSelectedIndexChange = { action() }, + index = index, + ) + } } } } } } } - - TopBarVariant.PrimaryHome -> { + currentScreen == AppScreen.Home -> { MiuixIconButton(onClick = { openExternalUrl(context, DOCUMENTATION_URL) }) { Icon( imageVector = MiuixIcons.Regular.Info, contentDescription = "文档", + tint = MaterialTheme.colorScheme.onSurface, ) } MiuixIconButton(onClick = { showSponsorDialog = true }) { Icon( imageVector = MiuixIcons.Regular.Create, contentDescription = "赞助", + tint = MaterialTheme.colorScheme.onSurface, ) } MiuixIconButton(onClick = { showRestartDialog = true }) { Icon( imageVector = MiuixIcons.Regular.Refresh, contentDescription = "重启作用域", + tint = MaterialTheme.colorScheme.onSurface, ) } } - - TopBarVariant.PrimaryApps -> { + currentScreen == AppScreen.Apps -> { MiuixIconButton( onClick = { if (!isAppsSearchExpanded) { @@ -1276,6 +1623,7 @@ private fun HyperIslandComposeApp() { Icon( imageVector = MiuixIcons.Regular.Search, contentDescription = "搜索", + tint = MaterialTheme.colorScheme.onSurface, ) } if (!appsSelectionMode) { @@ -1283,6 +1631,7 @@ private fun HyperIslandComposeApp() { Icon( imageVector = MiuixIcons.Regular.SelectAll, contentDescription = "进入多选", + tint = MaterialTheme.colorScheme.onSurface, ) } } @@ -1294,6 +1643,7 @@ private fun HyperIslandComposeApp() { Icon( imageVector = MiuixIcons.Regular.MoreCircle, contentDescription = "更多操作", + tint = MaterialTheme.colorScheme.onSurface, ) } OverlayListPopup( @@ -1359,90 +1709,89 @@ private fun HyperIslandComposeApp() { } } } - - TopBarVariant.PrimarySettings -> Unit + else -> Unit } }, - scrollBehavior = topBarScrollBehavior, - defaultWindowInsetsPadding = activeTopBarStyleState.defaultWindowInsetsPadding, ) - - if (isAppsRoute) { - if (isAppsSearchExpanded || appsSelectionMode) { - Column( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + + if (currentScreen == AppScreen.Apps && (isAppsSearchExpanded || appsSelectionMode)) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = 240)), + ) { + AnimatedVisibility( + visible = isAppsSearchExpanded, + enter = fadeIn(animationSpec = tween(durationMillis = 240)) + + expandVertically(animationSpec = tween(durationMillis = 240)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)) + + shrinkVertically(animationSpec = tween(durationMillis = 220)), + label = "apps_search_bar_visibility", ) { - AnimatedVisibility( - visible = isAppsSearchExpanded, - enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + - expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), - exit = fadeOut(animationSpec = tween(durationMillis = 180)) + - shrinkVertically(animationSpec = tween(durationMillis = 220)), - label = "apps_search_bar_visibility", - ) { - MiuixTextField( - value = appsSearchFieldValue, - onValueChange = { - appsSearchFieldValue = it - appsVm.setQuery(it.text) - }, - label = "搜索应用 / 包名", - useLabelAsPlaceholder = true, - modifier = Modifier - .focusRequester(appsSearchFocusRequester) - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) + MiuixTextField( + value = appsSearchFieldValue, + onValueChange = { + appsSearchFieldValue = it + appsVm.setQuery(it.text) + }, + label = "搜索应用 / 包名", + useLabelAsPlaceholder = true, + modifier = Modifier + .focusRequester(appsSearchFocusRequester) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + AnimatedVisibility( + visible = appsSelectionMode, + enter = fadeIn(animationSpec = tween(durationMillis = 240)) + + expandVertically(animationSpec = tween(durationMillis = 240)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)) + + shrinkVertically(animationSpec = tween(durationMillis = 220)), + label = "apps_selection_info_visibility", + ) { + val visiblePackages = appsState.filteredApps.map { it.packageName }.toSet() + val allVisibleSelected = visiblePackages.isNotEmpty() && + visiblePackages.all { appsState.selectedPackages.contains(it) } + val selectAllState = if (allVisibleSelected) { + ToggleableState.On + } else if (appsState.selectedPackages.isNotEmpty()) { + ToggleableState.Indeterminate + } else { + ToggleableState.Off } - - AnimatedVisibility( - visible = appsSelectionMode, - enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + - expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), - exit = fadeOut(animationSpec = tween(durationMillis = 180)) + - shrinkVertically(animationSpec = tween(durationMillis = 220)), - label = "apps_selection_info_visibility", + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - val visiblePackages = appsState.filteredApps.map { it.packageName }.toSet() - val allVisibleSelected = visiblePackages.isNotEmpty() && visiblePackages.all { appsState.selectedPackages.contains(it) } - val selectAllState = if (allVisibleSelected) ToggleableState.On else if (appsState.selectedPackages.isNotEmpty()) ToggleableState.Indeterminate else ToggleableState.Off - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("已选择 ${appsState.selectedPackages.size} 项", style = MaterialTheme.typography.bodySmall) - MiuixCheckbox( - state = selectAllState, - onClick = { - if (allVisibleSelected) { - appsVm.setSelectedPackages(appsState.selectedPackages - visiblePackages) - } else { - appsVm.setSelectedPackages(appsState.selectedPackages + visiblePackages) - } + Text("已选择 ${appsState.selectedPackages.size} 项", style = MaterialTheme.typography.bodySmall) + MiuixCheckbox( + state = selectAllState, + onClick = { + if (allVisibleSelected) { + appsVm.setSelectedPackages(appsState.selectedPackages - visiblePackages) + } else { + appsVm.setSelectedPackages(appsState.selectedPackages + visiblePackages) } - ) - } + }, + ) } } } } - - if (isBlacklistRoute) { + if (currentScreen == AppScreen.Blacklist) { Column( modifier = Modifier .fillMaxWidth() - .animateContentSize(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + .animateContentSize(animationSpec = tween(durationMillis = 240)), ) { AnimatedVisibility( visible = isBlacklistSearchExpanded, - enter = fadeIn(animationSpec = tween(durationMillis = searchExpandTransitionMs)) + - expandVertically(animationSpec = tween(durationMillis = searchExpandTransitionMs)), + enter = fadeIn(animationSpec = tween(durationMillis = 240)) + + expandVertically(animationSpec = tween(durationMillis = 240)), exit = fadeOut(animationSpec = tween(durationMillis = 180)) + shrinkVertically(animationSpec = tween(durationMillis = 220)), label = "blacklist_search_bar_visibility", @@ -1461,510 +1810,125 @@ private fun HyperIslandComposeApp() { .padding(horizontal = 16.dp, vertical = 8.dp), ) } - Text( - text = "已加入黑名单 ${blacklistState.blacklistedPackages.size} 项", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) } } } - } - }, - bottomBar = {}, // Handle bottom bar as overlay so list draws beneath it for `.textureBlur` - ) { innerPadding -> - val bottomOverlayPaddingTarget = when { - isSecondaryRoute -> 0.dp - activeNavStyleState.floating -> { - activeNavStyleState.floatingBottomOffset + - activeNavStyleState.floatingContainerHeight + - if (activeNavStyleState.floatingWindowInsetsPadding) { - insets.calculateBottomPadding() - } else { - 0.dp - } - } - else -> { - activeNavStyleState.bottomContainerHeight + - if (activeNavStyleState.bottomWindowInsetsPadding) { - insets.calculateBottomPadding() - } else { - 0.dp - } - } } - val bottomOverlayPadding by animateDpAsState( - targetValue = bottomOverlayPaddingTarget, - animationSpec = tween(durationMillis = 320), - label = "bottom_overlay_padding_transition", - ) - val combinedPadding = PaddingValues( - start = innerPadding.calculateStartPadding(layoutDirection), - end = innerPadding.calculateEndPadding(layoutDirection), - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding() + bottomOverlayPadding - ) - Box( - modifier = Modifier - .fillMaxSize() - ) { - // Background content capturing box - Box( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .consumeWindowInsets(combinedPadding), - ) { - CompositionLocalProvider(LocalContentPadding provides combinedPadding) { - NavHost( - navController = navController, - startDestination = "home", - enterTransition = { - val fromRoute = initialState.destination.route - val toRoute = targetState.destination.route - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) - when { - mainSwitchDirection != null -> { - slideIntoContainer( - mainSwitchDirection, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeIn( - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - toLevel > fromLevel -> { - // HyperCeiler-like push: new page overlays from right. - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeIn( - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - else -> { - val direction = resolveRouteForwardSlideDirection(fromRoute, toRoute) - if (direction != null) { - slideIntoContainer( - direction, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } else { - EnterTransition.None - } - } - } - }, - exitTransition = { - val fromRoute = initialState.destination.route - val toRoute = targetState.destination.route - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) - when { - mainSwitchDirection != null -> { - slideOutOfContainer( - mainSwitchDirection, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeOut( - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - toLevel > fromLevel -> { - // Keep background page as an underlay while pushing. - fadeOut( - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + scaleOut( - targetScale = 0.97f, - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - else -> { - val direction = resolveRouteForwardSlideDirection(fromRoute, toRoute) - if (direction != null) { - slideOutOfContainer( - direction, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } else { - ExitTransition.None - } - } - } - }, - popEnterTransition = { - val fromRoute = initialState.destination.route - val toRoute = targetState.destination.route - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) - when { - mainSwitchDirection != null -> { - slideIntoContainer( - mainSwitchDirection, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeIn( - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - toLevel < fromLevel -> { - // Underlay page re-appears from the back. - fadeIn( - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + scaleIn( - initialScale = 0.97f, - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - else -> { - val direction = resolveRoutePopSlideDirection(fromRoute, toRoute) - if (direction != null) { - slideIntoContainer( - direction, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } else { - EnterTransition.None - } - } - } - }, - popExitTransition = { - val fromRoute = initialState.destination.route - val toRoute = targetState.destination.route - val fromLevel = routeLevel(fromRoute) - val toLevel = routeLevel(toRoute) - val mainSwitchDirection = resolveMainSwitchDirection(fromRoute, toRoute) - when { - mainSwitchDirection != null -> { - slideOutOfContainer( - mainSwitchDirection, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeOut( - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - toLevel < fromLevel -> { - // Top overlay page leaves to right on back. - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) + fadeOut( - animationSpec = tween( - durationMillis = OVERLAY_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } - else -> { - val direction = resolveRoutePopSlideDirection(fromRoute, toRoute) - if (direction != null) { - slideOutOfContainer( - direction, - animationSpec = tween( - durationMillis = ROUTE_TRANSITION_DURATION_MS, - easing = FastOutSlowInEasing, - ), - ) - } else { - ExitTransition.None - } - } - } - }, - modifier = Modifier.fillMaxSize(), - ) { - composable("home") { - val uiState by homeVm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - homeVm.events.collect { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - HomeScreen( - uiState = uiState, - onRefresh = homeVm::refreshStatus, - onSendTest = homeVm::sendTest, - modifier = Modifier.nestedScroll(homeScrollBehavior.nestedScrollConnection), - ) - } - composable("apps") { - LaunchedEffect(Unit) { - appsVm.events.collect { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - AppsScreen( - state = appsState, - onRefresh = appsVm::refresh, - onQueryChange = appsVm::setQuery, - onAppEnabledChange = appsVm::setEnabled, - onAppSelectedChange = appsVm::toggleSelectedPackage, - onSelectAll = appsVm::setSelectedPackages, - onOpenAppChannels = { pkg -> navController.navigate("app_channels/$pkg") }, - onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, - onBatchApplySelected = appsVm::batchApplyToSelectedApps, - onSelectionModeChanged = { appsSelectionMode = it }, - - appListState = appListState, - selectionRequestId = appsSelectionRequestId, - exitSelectionRequestId = appsExitSelectionRequestId, - enableSelectedRequestId = appsEnableSelectedRequestId, - disableSelectedRequestId = appsDisableSelectedRequestId, - selectEnabledRequestId = appsSelectEnabledRequestId, - batchSelectedRequestId = appsBatchSelectedRequestId, - enableAllRequestId = appsEnableAllRequestId, - disableAllRequestId = appsDisableAllRequestId, - batchRequestId = appsBatchRequestId, - topAppBarScrollBehavior = appsScrollBehavior, - canPullToRefresh = true, - modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), - ) - } - composable("settings") { - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - ) { uri -> - if (uri != null) { - settingsVm.importConfigFromUri(uri) - } - } - LaunchedEffect(Unit) { - settingsVm.events.collect { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - SettingsScreen( - state = settingsState, - onToggle = settingsVm::updateSwitch, - onMarqueeSpeed = settingsVm::updateMarqueeSpeed, - onBigIslandWidth = settingsVm::updateBigIslandMaxWidth, - onThemeModeChange = settingsVm::updateThemeMode, - onLocaleChange = settingsVm::updateLocale, - onHideDesktopIcon = settingsVm::setDesktopIconHidden, - onOpenBlacklist = { navController.navigate("blacklist") }, - onOpenAiConfig = { navController.navigate("ai_config") }, - onCheckUpdate = { openExternalUrl(context, GITHUB_RELEASE_URL) }, - onOpenGithub = { openExternalUrl(context, GITHUB_REPO_URL) }, - onExportToFile = settingsVm::exportConfigToFile, - onPickImportFile = { importLauncher.launch(arrayOf("application/json", "text/plain")) }, - onExportToClipboard = settingsVm::exportConfigToClipboard, - onImportFromClipboard = settingsVm::importConfigFromClipboard, - modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), - ) - } - composable( - route = "app_channels/{packageName}", - arguments = listOf(navArgument("packageName") { type = NavType.StringType }), - ) { backStack -> - val vm: AppChannelsViewModel = viewModel() - val state by vm.uiState.collectAsStateWithLifecycle() - val packageNameArg = backStack.arguments?.getString("packageName").orEmpty() - LaunchedEffect(packageNameArg) { - vm.setPackageNameIfEmpty(packageNameArg) - } - AppChannelsScreen( - state = state, - onRefresh = vm::refresh, - onSetAppEnabled = vm::setAppEnabled, - onToggleChannel = vm::toggleChannel, - onEnableAllChannels = vm::enableAllChannels, - onOpenChannelSettings = { channelId, channelName -> - navController.navigate( - "channel_settings/${Uri.encode(packageNameArg)}/${Uri.encode(channelId)}/${Uri.encode(channelName)}", - ) - }, - onBatchApplyToEnabledChannels = vm::batchApplyToEnabledChannels, - enableAllRequestId = appChannelsEnableAllRequestId, - batchRequestId = appChannelsBatchRequestId, - modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), - ) - } - composable( - route = "channel_settings/{packageName}/{channelId}/{channelName}", - arguments = listOf( - navArgument("packageName") { type = NavType.StringType }, - navArgument("channelId") { type = NavType.StringType }, - navArgument("channelName") { type = NavType.StringType }, - ), - ) { backStack -> - val vm: AppChannelsViewModel = viewModel() - val state by vm.uiState.collectAsStateWithLifecycle() - val packageNameArg = Uri.decode( - backStack.arguments?.getString("packageName").orEmpty(), - ) - val channelIdArg = Uri.decode( - backStack.arguments?.getString("channelId").orEmpty(), - ) - LaunchedEffect(packageNameArg) { - vm.setPackageNameIfEmpty(packageNameArg) - } - ChannelSettingsScreen( - state = state, - channelId = channelIdArg, - onRefresh = vm::refresh, - onSetTemplate = { vm.setTemplate(channelIdArg, it) }, - onSetTimeout = { vm.setTimeout(channelIdArg, it) }, - onSetSetting = { setting, value -> vm.setSetting(channelIdArg, setting, value) }, - onSetHighlightColor = { vm.setHighlightColor(channelIdArg, it) }, - modifier = Modifier.nestedScroll(appsScrollBehavior.nestedScrollConnection), - ) - } - composable("blacklist") { - LaunchedEffect(Unit) { - blacklistVm.events.collect { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - BlacklistScreen( - state = blacklistState, - onRefresh = blacklistVm::refresh, - onQueryChange = blacklistVm::setQuery, - onSetBlacklisted = blacklistVm::setBlacklisted, - canPullToRefresh = false, - modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), - ) - } - composable("ai_config") { - val vm: AiConfigViewModel = viewModel() - val uiState by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.events.collect { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - AiConfigScreen( - state = uiState, - onUpdate = vm::setState, - onSave = vm::save, - onTest = vm::testConnection, - modifier = Modifier.nestedScroll(settingsScrollBehavior.nestedScrollConnection), - ) - } - } + }, + bottomBar = { + if (!isSecondaryRoute) { + HyperCeilerNavigationSwitchBar( + items = items, + selectedIndex = selectedIndexInBar, + style = activeNavStyleState, + onDestinationClick = onPrimaryDestinationClick, + backdrop = activeBackdrop, + ) + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .let { + if (activeBackdrop != null) { + it.layerBackdrop(activeBackdrop) + } else { + it } - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(combinedPadding.calculateTopPadding() + 72.dp) - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.surface.copy(alpha = androidx.compose.ui.util.lerp(0.18f, 0.56f, topBarCollapseProgress)), - MaterialTheme.colorScheme.surface.copy(alpha = androidx.compose.ui.util.lerp(0.08f, 0.32f, topBarCollapseProgress)), - Color.Transparent, - ), - ), - ), - ) - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(insets.calculateBottomPadding() + 96.dp) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.surface.copy(alpha = 0.24f), - MaterialTheme.colorScheme.surface.copy(alpha = 0.46f), - ), - ), - ), - ) - } // End of layerBackdrop box - - if (!isSecondaryRoute) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.BottomCenter, - ) { - HyperCeilerNavigationSwitchBar( - items = items, - selectedIndex = selectedIndex, - style = activeNavStyleState, - onDestinationClick = onPrimaryDestinationClick, - backdrop = backdrop, + } + .consumeWindowInsets(innerPadding), + ) { + AnimatedContent( + targetState = currentScreen, + label = "scene_content_switch", + transitionSpec = { + val initialDepth = screenDepth(initialState) + val targetDepth = screenDepth(targetState) + val initialTopIndex = topLevelIndexOf(initialState) + val targetTopIndex = topLevelIndexOf(targetState) + if (initialDepth == 1 && targetDepth == 1) { + val forward = targetTopIndex > initialTopIndex + ( + slideInHorizontally( + animationSpec = tween(durationMillis = 340), + initialOffsetX = { full -> if (forward) full / 6 else -full / 6 }, + ) + fadeIn(animationSpec = tween(durationMillis = 340)) + ) togetherWith ( + slideOutHorizontally( + animationSpec = tween(durationMillis = 260), + targetOffsetX = { full -> if (forward) -full / 12 else full / 12 }, + ) + fadeOut(animationSpec = tween(durationMillis = 220)) + ) + } else if (targetDepth > initialDepth) { + ( + slideInHorizontally( + animationSpec = tween(durationMillis = 320), + initialOffsetX = { full -> full / 3 }, + ) + fadeIn(animationSpec = tween(durationMillis = 320)) + ) togetherWith ( + slideOutHorizontally( + animationSpec = tween(durationMillis = 260), + targetOffsetX = { full -> -full / 6 }, + ) + fadeOut(animationSpec = tween(durationMillis = 220)) + ) + } else if (targetDepth < initialDepth) { + ( + slideInHorizontally( + animationSpec = tween(durationMillis = 300), + initialOffsetX = { full -> -full / 4 }, + ) + fadeIn(animationSpec = tween(durationMillis = 280)) + ) togetherWith ( + slideOutHorizontally( + animationSpec = tween(durationMillis = 240), + targetOffsetX = { full -> full / 3 }, + ) + fadeOut(animationSpec = tween(durationMillis = 200)) ) + } else { + val forward = targetTopIndex >= initialTopIndex + ( + slideInHorizontally( + animationSpec = tween(durationMillis = 280), + initialOffsetX = { full -> if (forward) full / 5 else -full / 5 }, + ) + fadeIn(animationSpec = tween(durationMillis = 280)) + ) togetherWith ( + slideOutHorizontally( + animationSpec = tween(durationMillis = 220), + targetOffsetX = { full -> if (forward) -full / 7 else full / 7 }, + ) + fadeOut(animationSpec = tween(durationMillis = 200)) + ) + } + }, + ) { scene -> + val topLevel = topLevelScreen(scene) + if (screenDepth(scene) == 1) { + val stateKey = when (topLevel) { + AppScreen.Home -> "top_home" + AppScreen.Apps -> "top_apps" + else -> "top_settings" + } + topLevelSaveableStateHolder.SaveableStateProvider(stateKey) { + SceneContent(scene = scene, innerPadding = innerPadding) } + } else { + SceneContent(scene = scene, innerPadding = innerPadding) } } - SponsorDialog( - show = showSponsorDialog, - onDismiss = { showSponsorDialog = false }, - ) - RestartScopeDialog( - show = showRestartDialog, - onDismiss = { showRestartDialog = false }, - onConfirm = { systemUi, downloads, xmsf -> - showRestartDialog = false - homeVm.restartScopes(systemUi, downloads, xmsf) - }, - ) } } + SponsorDialog( + show = showSponsorDialog, + onDismiss = { showSponsorDialog = false }, + ) + RestartScopeDialog( + show = showRestartDialog, + onDismiss = { showRestartDialog = false }, + onConfirm = { systemUi, downloads, xmsf -> + showRestartDialog = false + homeVm.restartScopes(systemUi, downloads, xmsf) + }, + ) } - @Composable private fun HomeScreen( uiState: HomeUiState, @@ -1994,25 +1958,79 @@ private fun HomeScreen( item { MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("模块状态", style = MaterialTheme.typography.titleMedium) val statusText = when (uiState.moduleActive) { - null -> "检测中..." + null -> "检测中" true -> "已激活" false -> "未激活" } - Text("LSPosed API: ${uiState.lsposedApiVersion}") - Text("模块状态: $statusText") - Text("Focus 协议版本: ${uiState.focusProtocolVersion}") + val statusAccent = when (uiState.moduleActive) { + null -> MaterialTheme.colorScheme.outline + true -> Color(0xFF38B46A) + false -> MaterialTheme.colorScheme.error + } + val statusHint = when (uiState.moduleActive) { + null -> "正在读取作用域状态" + true -> "模块与作用域工作正常" + false -> "请检查 LSPosed 激活与作用域重启" + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + "模块状态", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + statusHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(statusAccent.copy(alpha = 0.14f)) + .border( + width = 1.dp, + color = statusAccent.copy(alpha = 0.42f), + shape = RoundedCornerShape(999.dp), + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = statusText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = statusAccent, + ) + } + } + Spacer(modifier = Modifier.height(2.dp)) + HomeStatusInfoRow( + label = "LSPosed API", + value = uiState.lsposedApiVersion.toString(), + ) + HomeStatusInfoRow( + label = "Focus 协议版本", + value = uiState.focusProtocolVersion.toString(), + ) } } } item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { MiuixButton(onClick = onRefresh, modifier = Modifier.weight(1f)) { - Text("刷新状态") + Text("刷新状态", color = MaterialTheme.colorScheme.onBackground) } MiuixButton(onClick = onSendTest, modifier = Modifier.weight(1f)) { - Text("发送测试通知") + Text("发送测试通知", color = MaterialTheme.colorScheme.onBackground) } } } @@ -2054,10 +2072,35 @@ private fun HomeScreen( } } +@Composable +private fun HomeStatusInfoRow( + label: String, + value: String, + valueColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = valueColor, + ) + } +} + @Composable private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { val context = LocalContext.current - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() val panelShape = RoundedCornerShape(16.dp) val qrBitmap = remember { runCatching { @@ -2131,7 +2174,7 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { onClick = onDismiss, modifier = Modifier.fillMaxWidth(), ) { - Text("关闭") + Text("关闭", color = MaterialTheme.colorScheme.onBackground) } } } @@ -2143,7 +2186,7 @@ private fun RestartScopeDialog( onDismiss: () -> Unit, onConfirm: (Boolean, Boolean, Boolean) -> Unit, ) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() val scopeCardShape = RoundedCornerShape(16.dp) var restartSystemUi by remember { mutableStateOf(true) } var restartDownloads by remember { mutableStateOf(true) } @@ -2225,7 +2268,7 @@ private fun RestartScopeDialog( }, modifier = Modifier.weight(1f), ) { - Text(if (allSelected) "全不选" else "全选") + Text(if (allSelected) "全不选" else "全选", color = MaterialTheme.colorScheme.onBackground) } MiuixButton( onClick = { onConfirm(restartSystemUi, restartDownloads, restartXmsf) }, @@ -2256,7 +2299,11 @@ private fun ScopeCheckboxRow(title: String, subtitle: String, checked: Boolean, modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + ) Text( subtitle, style = MaterialTheme.typography.bodySmall, @@ -2451,7 +2498,7 @@ private fun SettingsScreen( ) { onToggle(PrefKeys.ROUND_ICON, it) } ToggleItem( "悬浮底部导航栏", - "启用后使用 FloatingNavigationBar 样式", + "", state.useFloatingNavigationBar, ) { onToggle(PrefKeys.USE_FLOATING_NAVIGATION_BAR, it) } SliderItem( @@ -2467,7 +2514,7 @@ private fun SettingsScreen( ) ToggleSliderItem( "修改超级岛最大宽度", - "开启后修改超级岛的最大宽度", + "", state.bigIslandMaxWidthEnabled, valueText = "${state.bigIslandMaxWidth} dp", value = state.bigIslandMaxWidth.toFloat(), @@ -2625,7 +2672,13 @@ private fun MainActivityPreview() { contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { MiuixTopAppBar( - title = routeTitle(selectedRoute), + title = screenTitle( + when (selectedRoute) { + "apps" -> AppScreen.Apps + "settings" -> AppScreen.Settings + else -> AppScreen.Home + }, + ), scrollBehavior = activePrimaryScrollBehavior, defaultWindowInsetsPadding = false, ) @@ -2791,7 +2844,11 @@ private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Un .weight(1f) .padding(end = 8.dp), ) { - Text(title, style = MaterialTheme.typography.titleMedium) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) if (subtitle.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -2808,6 +2865,7 @@ private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Un Icon( imageVector = MiuixIcons.Basic.ArrowRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -2815,9 +2873,13 @@ private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Un @Composable private fun SectionTitle(title: String) { - MiuixSmallTitle( + val isDarkTheme = isAppInDarkTheme() + Text( text = title, - insideMargin = PaddingValues(horizontal = 12.dp, vertical = 2.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isDarkTheme) 0.92f else 0.88f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) } @@ -2845,7 +2907,11 @@ private fun ToggleItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) if (subtitle.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -2878,28 +2944,23 @@ private fun SliderItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(title, style = MaterialTheme.typography.titleMedium) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { - if (showResetButton) { - MiuixIconButton( - onClick = onResetToDefault, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "恢复默认值", - modifier = Modifier.size(14.dp), - ) - } - } - } + SliderResetButton( + show = showResetButton, + onClick = onResetToDefault, + ) Text( valueText, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.End, modifier = Modifier.width(72.dp), ) @@ -2946,7 +3007,11 @@ private fun ToggleSliderItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) if (subtitle.isNotEmpty()) { Text( subtitle, @@ -2975,23 +3040,14 @@ private fun ToggleSliderItem( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { - if (showResetButton) { - MiuixIconButton( - onClick = onResetToDefault, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "恢复默认值", - modifier = Modifier.size(14.dp), - ) - } - } - } + SliderResetButton( + show = showResetButton, + onClick = onResetToDefault, + ) Text( valueText, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.End, modifier = Modifier.width(72.dp), ) @@ -3001,3 +3057,24 @@ private fun ToggleSliderItem( } } +@Composable +private fun SliderResetButton( + show: Boolean, + onClick: () -> Unit, +) { + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + if (!show) return@Box + MiuixIconButton( + onClick = onClick, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "恢复默认值", + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt index c3cd8f2d..1b7efa2d 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/FaIcon.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle @@ -40,6 +41,7 @@ fun FaIcon( modifier: Modifier = Modifier, fontSize: TextUnit = 20.sp, style: FaStyle = FaStyle.Solid, + tint: Color = LocalContentColor.current, ) { val iconModifier = if (contentDescription != null) { modifier.semantics { this.contentDescription = contentDescription } @@ -53,7 +55,7 @@ fun FaIcon( ) { Text( text = glyph.glyph, - color = LocalContentColor.current, + color = tint, style = TextStyle( fontFamily = when (style) { FaStyle.Solid -> fontAwesomeSolidFamily diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt index e97001a9..e958035e 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -47,6 +46,7 @@ import top.yukonga.miuix.kmp.icon.extended.Hide import top.yukonga.miuix.kmp.icon.extended.Show import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import io.github.hyperisland.ui.isAppInDarkTheme private const val DEFAULT_AI_TIMEOUT = 3 private const val DEFAULT_AI_TEMPERATURE = 0.1 @@ -57,7 +57,7 @@ private fun aiCardModifier( modifier: Modifier = Modifier, shape: RoundedCornerShape = RoundedCornerShape(18.dp), ): Modifier { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() return modifier .clip(shape) .then( @@ -103,7 +103,12 @@ fun AiConfigScreen( ), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("AI 增强", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "AI 增强", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Row( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), @@ -111,9 +116,13 @@ fun AiConfigScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("启用 AI 摘要") + Text("启用 AI 摘要", color = MaterialTheme.colorScheme.onBackground) Spacer(modifier = Modifier.height(4.dp)) - Text("由 AI 生成超级岛左右文本,超时或失败时自动回退", style = MaterialTheme.typography.bodySmall) + Text( + "由 AI 生成超级岛左右文本,超时或失败时自动回退", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } MiuixSwitch( checked = state.enabled, @@ -124,7 +133,12 @@ fun AiConfigScreen( Spacer(modifier = Modifier.height(4.dp)) if (state.enabled) { - Text("API 参数", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "API 参数", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { MiuixTextField( @@ -145,13 +159,14 @@ fun AiConfigScreen( visualTransformation = if (keyObscured) PasswordVisualTransformation() else VisualTransformation.None, trailingIcon = { MiuixIconButton(onClick = { keyObscured = !keyObscured }) { - Icon( - imageVector = if (keyObscured) MiuixIcons.Regular.Show else MiuixIcons.Regular.Hide, - contentDescription = if (keyObscured) "显示密钥" else "隐藏密钥", - ) - } - }, - ) + Icon( + imageVector = if (keyObscured) MiuixIcons.Regular.Show else MiuixIcons.Regular.Hide, + contentDescription = if (keyObscured) "显示密钥" else "隐藏密钥", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) MiuixTextField( value = state.model, onValueChange = { onUpdate(state.copy(model = it)) }, @@ -176,9 +191,13 @@ fun AiConfigScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("提示词放在用户消息") + Text("提示词放在用户消息", color = MaterialTheme.colorScheme.onBackground) Spacer(modifier = Modifier.height(4.dp)) - Text("某些模型不支持系统指令,开启后将提示词放在用户消息中", style = MaterialTheme.typography.bodySmall) + Text( + "某些模型不支持系统指令,开启后将提示词放在用户消息中", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } MiuixSwitch( checked = state.promptInUser, @@ -222,10 +241,13 @@ fun AiConfigScreen( Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { MiuixButton(onClick = onTest, enabled = !state.testing, modifier = Modifier.weight(1f)) { - Text(if (state.testing) "测试中..." else "测试连接") + Text( + if (state.testing) "测试中..." else "测试连接", + color = MaterialTheme.colorScheme.onBackground, + ) } MiuixButton(onClick = onSave, modifier = Modifier.weight(1f)) { - Text("保存") + Text("保存", color = MaterialTheme.colorScheme.onBackground) } } @@ -241,6 +263,7 @@ fun AiConfigScreen( Text( "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -269,34 +292,29 @@ private fun SliderItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 10.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall) + Text(title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onBackground) if (subtitle.isNotBlank()) { Spacer(modifier = Modifier.height(2.dp)) - Text(subtitle, style = MaterialTheme.typography.bodySmall) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { - if (showResetButton) { - MiuixIconButton( - onClick = onResetToDefault, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "恢复默认值", - modifier = Modifier.size(14.dp), - ) - } - } - } + SliderResetButton( + show = showResetButton, + onClick = onResetToDefault, + ) Text( valueText, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.End, modifier = Modifier.width(64.dp), ) @@ -312,6 +330,27 @@ private fun SliderItem( } } +@Composable +private fun SliderResetButton( + show: Boolean, + onClick: () -> Unit, +) { + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + if (!show) return@Box + MiuixIconButton( + onClick = onClick, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = MiuixIcons.Regular.Refresh, + contentDescription = "恢复默认值", + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun TestResultCard(text: String) { val isSuccess = text.isNotBlank() && !text.startsWith("HTTP ") && !text.contains("Exception") diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 2938dbf4..4f63028f 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -55,9 +55,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.foundation.isSystemInDarkTheme import io.github.hyperisland.ui.FaGlyph import io.github.hyperisland.ui.FaIcon +import io.github.hyperisland.ui.isAppInDarkTheme import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults import top.yukonga.miuix.kmp.basic.Card as MiuixCard @@ -90,7 +90,7 @@ private fun sectionCardModifier( modifier: Modifier = Modifier, shape: RoundedCornerShape = RoundedCornerShape(18.dp), ): Modifier { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() return modifier .clip(shape) .then( @@ -402,6 +402,7 @@ fun AppChannelsScreen( text = if (state.appName.isBlank()) state.packageName else state.appName, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, ) Text( state.packageName, @@ -439,7 +440,9 @@ fun AppChannelsScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text(it, color = MaterialTheme.colorScheme.error) - MiuixButton(onClick = onRefresh) { Text("重试") } + MiuixButton(onClick = onRefresh) { + Text("重试", color = MaterialTheme.colorScheme.onBackground) + } } } return@LazyColumn @@ -473,7 +476,9 @@ fun AppChannelsScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - MiuixButton(onClick = onRefresh) { Text("刷新") } + MiuixButton(onClick = onRefresh) { + Text("刷新", color = MaterialTheme.colorScheme.onBackground) + } } } } else { @@ -543,7 +548,9 @@ fun ChannelSettingsScreen( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text(state.error, color = MaterialTheme.colorScheme.error) - MiuixButton(onClick = onRefresh) { Text("重试") } + MiuixButton(onClick = onRefresh) { + Text("重试", color = MaterialTheme.colorScheme.onBackground) + } } return } @@ -556,7 +563,9 @@ fun ChannelSettingsScreen( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text("未找到该通知渠道", color = MaterialTheme.colorScheme.onSurfaceVariant) - MiuixButton(onClick = onRefresh) { Text("刷新") } + MiuixButton(onClick = onRefresh) { + Text("刷新", color = MaterialTheme.colorScheme.onBackground) + } } return } @@ -640,7 +649,11 @@ private fun ChannelListItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text(channel.name, fontWeight = FontWeight.SemiBold) + Text( + channel.name, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -664,6 +677,11 @@ private fun ChannelListItem( Icon( imageVector = MiuixIcons.Regular.Settings, contentDescription = "渠道设置", + tint = if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, ) } MiuixSwitch( @@ -672,7 +690,11 @@ private fun ChannelListItem( ) } if (channel.description.isNotBlank()) { - Text("描述: ${channel.description}", style = MaterialTheme.typography.bodySmall) + Text( + "描述: ${channel.description}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -998,7 +1020,7 @@ private fun InputDialogRow( onClick = { showDialog = false }, modifier = Modifier.weight(1f), ) { - Text("取消") + Text("取消", color = MaterialTheme.colorScheme.onBackground) } MiuixButton( onClick = { @@ -1095,6 +1117,7 @@ private fun BatchApplyDialog( FaIcon( glyph = FaGlyph.Times, contentDescription = "取消", + tint = MaterialTheme.colorScheme.onBackground, ) } }, @@ -1138,6 +1161,7 @@ private fun BatchApplyDialog( FaIcon( glyph = FaGlyph.Check, contentDescription = "应用", + tint = MaterialTheme.colorScheme.onBackground, ) } }, @@ -1256,8 +1280,16 @@ private fun AppItemRow( AppListIcon(app = app) Spacer(modifier = Modifier.size(10.dp)) Column(modifier = Modifier.weight(1f)) { - Text(app.appName, fontWeight = FontWeight.SemiBold) - Text(app.packageName, style = MaterialTheme.typography.bodySmall) + Text( + app.appName, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } if (selectionMode) { MiuixCheckbox( diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt index 2d102f6f..fea392c1 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,6 +28,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.hyperisland.ui.isAppInDarkTheme import io.github.hyperisland.ui.app.AppIcon import io.github.hyperisland.ui.app.AppItem import top.yukonga.miuix.kmp.icon.MiuixIcons @@ -49,7 +49,7 @@ private fun blacklistCardModifier( modifier: Modifier = Modifier, shape: RoundedCornerShape = RoundedCornerShape(18.dp), ): Modifier { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = isAppInDarkTheme() return modifier .clip(shape) .then( @@ -177,6 +177,7 @@ private fun EmptyBlacklistState( Text( text = if (query.isBlank()) "没有可显示的应用" else "没有匹配的应用", fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, ) Text( text = if (query.isBlank()) "可以尝试显示系统应用或下拉刷新。" else "换个关键词试试。", @@ -185,7 +186,7 @@ private fun EmptyBlacklistState( ) if (query.isNotBlank()) { MiuixButton(onClick = onClearQuery) { - Text("清空搜索") + Text("清空搜索", color = MaterialTheme.colorScheme.onBackground) } } } @@ -217,6 +218,7 @@ private fun BlacklistAppRow( Text( text = app.appName, fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, maxLines = 1, ) Spacer(modifier = Modifier.height(4.dp)) diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 06e0b191..f2518a8d 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -12,11 +12,78 @@ class MainPage extends StatefulWidget { State createState() => _MainPageState(); } -class _MainPageState extends State { +class _MainPageState extends State + with SingleTickerProviderStateMixin { int _currentIndex = 0; + int? _previousIndex; // WhitelistPage 懒创建:首次点击「应用」Tab 时才初始化,避免启动时触发权限申请 WhitelistPage? _whitelistPage; final _whitelistKey = GlobalKey(); + late final AnimationController _tabSwitchController; + + bool get _isTabSwitching => _previousIndex != null; + + @override + void initState() { + super.initState(); + _tabSwitchController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + )..addStatusListener((status) { + if (status == AnimationStatus.completed && mounted) { + setState(() => _previousIndex = null); + } + }); + } + + @override + void dispose() { + _tabSwitchController.dispose(); + super.dispose(); + } + + List _buildTabs() => [ + const HomePage(), + _whitelistPage ??= WhitelistPage(key: _whitelistKey), + const SettingsPage(), + ]; + + Widget _buildAnimatedBody() { + final tabs = _buildTabs(); + final previousIndex = _previousIndex; + if (previousIndex == null) { + return tabs[_currentIndex]; + } + + final progress = Curves.easeOutCubic.transform(_tabSwitchController.value); + final direction = _currentIndex > previousIndex ? 1.0 : -1.0; + + return Stack( + children: List.generate(tabs.length, (index) { + final isCurrent = index == _currentIndex; + final isPrevious = index == previousIndex; + final isVisible = isCurrent || isPrevious; + final enterX = 0.08 * direction * (1 - progress); + final exitX = -0.04 * direction * progress; + + return Offstage( + offstage: !isVisible, + child: IgnorePointer( + ignoring: true, + child: AnimatedOpacity( + duration: Duration.zero, + opacity: isCurrent ? (0.75 + (0.25 * progress)) : (1 - progress), + child: FractionalTranslation( + translation: Offset(isCurrent ? enterX : exitX, 0), + child: tabs[index], + ), + ), + ), + ); + }), + ); + } @override Widget build(BuildContext context) { @@ -26,6 +93,7 @@ class _MainPageState extends State { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; + if (_isTabSwitching) return; // 先尝试让当前 tab 的子页面消费返回事件 if (_currentIndex == 1) { final state = _whitelistKey.currentState; @@ -35,22 +103,23 @@ class _MainPageState extends State { SystemNavigator.pop(); }, child: Scaffold( - body: IndexedStack( - index: _currentIndex, - children: [ - const HomePage(), - _whitelistPage ??= WhitelistPage(key: _whitelistKey), - const SettingsPage(), - ], + body: AnimatedBuilder( + animation: _tabSwitchController, + builder: (_, __) => _buildAnimatedBody(), ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: (index) { + if (index == _currentIndex || _isTabSwitching) return; FocusScope.of(context).unfocus(); if (index == 1 && _whitelistPage == null) { _whitelistPage = WhitelistPage(key: _whitelistKey); } - setState(() => _currentIndex = index); + setState(() { + _previousIndex = _currentIndex; + _currentIndex = index; + }); + _tabSwitchController.forward(from: 0); }, destinations: [ NavigationDestination( From 41a38bcdc2399fb6f321774ce33ff3b008ba86e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 14:42:35 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E9=80=82=E9=85=8D=E8=8B=B1=E8=AF=AD(?= =?UTF-8?q?=E6=9C=AA=E5=AE=8C=E5=85=A8=E9=80=82=E9=85=8D).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyperisland/ui/ComposeMainActivity.kt | 421 ++++++++++-------- .../github/hyperisland/ui/UiLocalization.kt | 37 ++ .../ui/app/AppAdaptationRepository.kt | 34 +- .../hyperisland/ui/app/AppChannelsUiState.kt | 3 + .../ui/app/AppChannelsViewModel.kt | 3 + .../github/hyperisland/ui/app/AppsScreens.kt | 391 +++++++++++----- .../ui/blacklist/BlacklistScreen.kt | 4 +- 7 files changed, 576 insertions(+), 317 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/hyperisland/ui/UiLocalization.kt diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index 0bdaea4f..8a7458da 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -47,7 +47,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -106,6 +105,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow +import androidx.compose.ui.zIndex import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -273,14 +273,14 @@ private fun topLevelScreen(screen: AppScreen): AppScreen = when (screen) { AppScreen.AiConfig -> AppScreen.Settings } -private fun screenTitle(screen: AppScreen): String = when (screen) { - AppScreen.Home -> "主页" - AppScreen.Apps -> "应用" - AppScreen.Settings -> "设置" - is AppScreen.AppChannels -> "渠道设置" - is AppScreen.ChannelSettings -> "渠道详情" - AppScreen.Blacklist -> "通知黑名单" - AppScreen.AiConfig -> "AI 配置" +private fun screenTitle(language: UiLanguage, screen: AppScreen): String = when (screen) { + AppScreen.Home -> textOf(language, "主页", "Home") + AppScreen.Apps -> textOf(language, "应用", "Apps") + AppScreen.Settings -> textOf(language, "设置", "Settings") + is AppScreen.AppChannels -> textOf(language, "渠道设置", "Channel Settings") + is AppScreen.ChannelSettings -> textOf(language, "渠道详情", "Channel Details") + AppScreen.Blacklist -> textOf(language, "通知黑名单", "Notification Blacklist") + AppScreen.AiConfig -> textOf(language, "AI 配置", "AI Config") } private fun screenDepth(screen: AppScreen): Int = when (screen) { @@ -715,12 +715,6 @@ private fun OverlayPopupMenuContainer(content: @Composable () -> Unit) { val containerShape = RoundedCornerShape(16.dp) Box( modifier = Modifier - .clip(containerShape) - .background( - MaterialTheme.colorScheme.surface.copy( - alpha = if (isDarkTheme) 0.95f else 0.98f, - ), - ) .then( if (isDarkTheme) { Modifier.border( @@ -733,8 +727,18 @@ private fun OverlayPopupMenuContainer(content: @Composable () -> Unit) { }, ), ) { - MiuixListPopupColumn { - content() + Box( + modifier = Modifier + .clip(containerShape) + .background( + MaterialTheme.colorScheme.surface.copy( + alpha = if (isDarkTheme) 0.95f else 0.98f, + ), + ), + ) { + MiuixListPopupColumn { + content() + } } } } @@ -785,7 +789,9 @@ private fun MiuiBlurredSurface( setBackgroundColor(android.graphics.Color.TRANSPARENT) } }, - modifier = Modifier.matchParentSize(), + modifier = Modifier + .matchParentSize() + .zIndex(-3f), update = { view -> nativeBlurApplied = MiuiTopBarBlurCompat.apply(view, context, blurRadiusPx) }, @@ -793,6 +799,7 @@ private fun MiuiBlurredSurface( Box( modifier = Modifier .matchParentSize() + .zIndex(-2f) .let { if (useFallbackBlur) { it.textureBlur( @@ -811,9 +818,12 @@ private fun MiuiBlurredSurface( Box( modifier = Modifier .matchParentSize() - .background(MaterialTheme.colorScheme.surface.copy(alpha = overlayAlpha)), + .zIndex(-1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = overlayAlpha)), ) - content() + Box(modifier = Modifier.zIndex(1f)) { + content() + } } } @@ -930,6 +940,7 @@ private fun HyperIslandComposeApp() { val blacklistVm: BlacklistViewModel = viewModel() val settingsVm: SettingsViewModel = viewModel() val settingsState by settingsVm.uiState.collectAsStateWithLifecycle() + val uiLanguage = rememberUiLanguage(settingsState.locale) var useFloatingNavigationBarUi by remember { mutableStateOf(settingsState.useFloatingNavigationBar) } LaunchedEffect(settingsState.useFloatingNavigationBar) { useFloatingNavigationBarUi = settingsState.useFloatingNavigationBar @@ -963,9 +974,9 @@ private fun HyperIslandComposeApp() { val popupShowing = showAppsMenu || showAppChannelsMenu || showBlacklistMenu val items = listOf( - TopLevelDestination("home", "主页", HomeFilledIcon), - TopLevelDestination("apps", "应用", MiuixIcons.Regular.All), - TopLevelDestination("settings", "设置", SettingsFilledIcon), + TopLevelDestination("home", textOf(uiLanguage, "主页", "Home"), HomeFilledIcon), + TopLevelDestination("apps", textOf(uiLanguage, "应用", "Apps"), MiuixIcons.Regular.All), + TopLevelDestination("settings", textOf(uiLanguage, "设置", "Settings"), SettingsFilledIcon), ) val capsuleNavStyleState = remember { NavigationStyleState( @@ -1118,9 +1129,9 @@ private fun HyperIslandComposeApp() { navigateTopLevel(target) } val topBarTitle = if (currentScreen is AppScreen.ChannelSettings) { - currentScreen.channelName.ifBlank { "渠道详情" } + currentScreen.channelName.ifBlank { textOf(uiLanguage, "渠道详情", "Channel Details") } } else { - screenTitle(currentScreen) + screenTitle(uiLanguage, currentScreen) } val isSecondaryRoute = isAppChannelsScreen || isChannelSettingsScreen || isBlacklistScreen || isAiConfigScreen LaunchedEffect(currentTopLevelScreen, isSecondaryRoute) { @@ -1303,7 +1314,11 @@ private fun HyperIslandComposeApp() { onAppEnabledChange = appsVm::setEnabled, onAppSelectedChange = appsVm::toggleSelectedPackage, onSelectAll = appsVm::setSelectedPackages, - onOpenAppChannels = { pkg -> navigateTo(AppScreen.AppChannels(pkg)) }, + onOpenAppChannels = { pkg -> + appChannelsEnableAllRequestId = 0 + appChannelsBatchRequestId = 0 + navigateTo(AppScreen.AppChannels(pkg)) + }, onBatchApplyGlobal = appsVm::batchApplyToAllEnabledApps, onBatchApplySelected = appsVm::batchApplyToSelectedApps, onSelectionModeChanged = { appsSelectionMode = it }, @@ -1425,15 +1440,16 @@ private fun HyperIslandComposeApp() { } } - MiuixScaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - MiuiBlurredTopBar( - modifier = Modifier.fillMaxWidth(), - fallbackBackdrop = activeBackdrop, - fallbackBlurColors = topBarFallbackBlurColors, - ) { - Column(modifier = Modifier.fillMaxWidth()) { + CompositionLocalProvider(LocalUiLanguage provides uiLanguage) { + MiuixScaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuiBlurredTopBar( + modifier = Modifier.fillMaxWidth(), + fallbackBackdrop = activeBackdrop, + fallbackBlurColors = topBarFallbackBlurColors, + ) { + Column(modifier = Modifier.fillMaxWidth()) { MiuixTopAppBar( title = topBarTitle, scrollBehavior = activeTopBarScrollBehavior, @@ -1470,7 +1486,7 @@ private fun HyperIslandComposeApp() { when { isSecondaryRoute -> { when { - currentScreen is AppScreen.AppChannels || currentScreen is AppScreen.ChannelSettings -> { + currentScreen is AppScreen.AppChannels -> { Box { BackHandler(enabled = showAppChannelsMenu) { showAppChannelsMenu = false @@ -1767,7 +1783,16 @@ private fun HyperIslandComposeApp() { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("已选择 ${appsState.selectedPackages.size} 项", style = MaterialTheme.typography.bodySmall) + val selectedTextColor = if (isAppInDarkTheme()) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.92f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Text( + "已选择 ${appsState.selectedPackages.size} 项", + style = MaterialTheme.typography.bodySmall, + color = selectedTextColor, + ) MiuixCheckbox( state = selectAllState, onClick = { @@ -1812,37 +1837,37 @@ private fun HyperIslandComposeApp() { } } } - } - } - }, - bottomBar = { - if (!isSecondaryRoute) { - HyperCeilerNavigationSwitchBar( - items = items, - selectedIndex = selectedIndexInBar, - style = activeNavStyleState, - onDestinationClick = onPrimaryDestinationClick, - backdrop = activeBackdrop, - ) - } - }, - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .let { - if (activeBackdrop != null) { - it.layerBackdrop(activeBackdrop) - } else { - it } } - .consumeWindowInsets(innerPadding), - ) { - AnimatedContent( - targetState = currentScreen, - label = "scene_content_switch", - transitionSpec = { + }, + bottomBar = { + if (!isSecondaryRoute) { + HyperCeilerNavigationSwitchBar( + items = items, + selectedIndex = selectedIndexInBar, + style = activeNavStyleState, + onDestinationClick = onPrimaryDestinationClick, + backdrop = activeBackdrop, + ) + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .let { + if (activeBackdrop != null) { + it.layerBackdrop(activeBackdrop) + } else { + it + } + } + .consumeWindowInsets(innerPadding), + ) { + AnimatedContent( + targetState = currentScreen, + label = "scene_content_switch", + transitionSpec = { val initialDepth = screenDepth(initialState) val targetDepth = screenDepth(targetState) val initialTopIndex = topLevelIndexOf(initialState) @@ -1914,20 +1939,22 @@ private fun HyperIslandComposeApp() { SceneContent(scene = scene, innerPadding = innerPadding) } } + + SponsorDialog( + show = showSponsorDialog, + onDismiss = { showSponsorDialog = false }, + ) + RestartScopeDialog( + show = showRestartDialog, + onDismiss = { showRestartDialog = false }, + onConfirm = { systemUi, downloads, xmsf -> + showRestartDialog = false + homeVm.restartScopes(systemUi, downloads, xmsf) + }, + ) + } } } - SponsorDialog( - show = showSponsorDialog, - onDismiss = { showSponsorDialog = false }, - ) - RestartScopeDialog( - show = showRestartDialog, - onDismiss = { showRestartDialog = false }, - onConfirm = { systemUi, downloads, xmsf -> - showRestartDialog = false - homeVm.restartScopes(systemUi, downloads, xmsf) - }, - ) } @Composable private fun HomeScreen( @@ -1937,10 +1964,10 @@ private fun HomeScreen( modifier: Modifier = Modifier, ) { val notes = listOf( - "1.此页面仅用于测试是否支持超级岛,并不代表实际效果", - "2.请在 HyperCeiler 中关闭系统界面和小米服务框架的焦点通知白名单", - "3.LSPosed 管理器中激活后,必须重启相关作用域软件", - "4.支持通用适配,自行勾选合适的模板尝试", + textOf("1.此页面仅用于测试是否支持超级岛,并不代表实际效果", "1. This page only checks whether Dynamic Island is supported and does not represent the final effect."), + textOf("2.请在 HyperCeiler 中关闭系统界面和小米服务框架的焦点通知白名单", "2. Disable the focus notification whitelist for System UI and Xiaomi Service Framework in HyperCeiler."), + textOf("3.LSPosed 管理器中激活后,必须重启相关作用域软件", "3. After enabling the module in LSPosed Manager, you must restart the related scope apps."), + textOf("4.支持通用适配,自行勾选合适的模板尝试", "4. Generic adaptation is supported, so try the templates that fit your app."), ) val contentPadding = LocalContentPadding.current LazyColumn( @@ -1959,9 +1986,9 @@ private fun HomeScreen( MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { val statusText = when (uiState.moduleActive) { - null -> "检测中" - true -> "已激活" - false -> "未激活" + null -> textOf("检测中", "Checking") + true -> textOf("已激活", "Active") + false -> textOf("未激活", "Inactive") } val statusAccent = when (uiState.moduleActive) { null -> MaterialTheme.colorScheme.outline @@ -1969,9 +1996,9 @@ private fun HomeScreen( false -> MaterialTheme.colorScheme.error } val statusHint = when (uiState.moduleActive) { - null -> "正在读取作用域状态" - true -> "模块与作用域工作正常" - false -> "请检查 LSPosed 激活与作用域重启" + null -> textOf("正在读取作用域状态", "Reading scope status") + true -> textOf("模块与作用域工作正常", "Module and scopes are working correctly") + false -> textOf("请检查 LSPosed 激活与作用域重启", "Check LSPosed activation and scope restart") } Row( modifier = Modifier.fillMaxWidth(), @@ -1983,7 +2010,7 @@ private fun HomeScreen( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - "模块状态", + textOf("模块状态", "Module Status"), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) @@ -2018,7 +2045,7 @@ private fun HomeScreen( value = uiState.lsposedApiVersion.toString(), ) HomeStatusInfoRow( - label = "Focus 协议版本", + label = textOf("Focus 协议版本", "Focus Protocol"), value = uiState.focusProtocolVersion.toString(), ) } @@ -2027,15 +2054,15 @@ private fun HomeScreen( item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { MiuixButton(onClick = onRefresh, modifier = Modifier.weight(1f)) { - Text("刷新状态", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("刷新状态", "Refresh Status"), color = MaterialTheme.colorScheme.onBackground) } MiuixButton(onClick = onSendTest, modifier = Modifier.weight(1f)) { - Text("发送测试通知", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("发送测试通知", "Send Test Notification"), color = MaterialTheme.colorScheme.onBackground) } } } item { - MiuixSmallTitle(text = "注意事项") + MiuixSmallTitle(text = textOf("注意事项", "Notes")) } item { MiuixCard(modifier = primaryCardModifier(Modifier.fillMaxWidth())) { @@ -2111,8 +2138,8 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { } OverlayDialog( show = show, - title = "赞助支持", - summary = "赞助作者", + title = textOf("赞助支持", "Support the Project"), + summary = textOf("赞助作者", "Support the Author"), onDismissRequest = onDismiss, onDismissFinished = {}, renderInRootScaffold = false, @@ -2126,7 +2153,7 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "支持项目持续更新,感谢你的认可。", + text = textOf("支持项目持续更新,感谢你的认可。", "Thank you for supporting the project and its continued updates."), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), @@ -2157,7 +2184,7 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { ) { Image( bitmap = qrBitmap.asImageBitmap(), - contentDescription = "微信赞助二维码", + contentDescription = textOf("微信赞助二维码", "WeChat sponsor QR code"), modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)), @@ -2165,7 +2192,7 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { } } else { Text( - text = "未找到赞助图片 assets/images/wechat.jpg", + text = textOf("未找到赞助图片 assets/images/wechat.jpg", "Sponsor image assets/images/wechat.jpg was not found"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -2174,7 +2201,7 @@ private fun SponsorDialog(show: Boolean, onDismiss: () -> Unit) { onClick = onDismiss, modifier = Modifier.fillMaxWidth(), ) { - Text("关闭", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("关闭", "Close"), color = MaterialTheme.colorScheme.onBackground) } } } @@ -2186,8 +2213,6 @@ private fun RestartScopeDialog( onDismiss: () -> Unit, onConfirm: (Boolean, Boolean, Boolean) -> Unit, ) { - val isDarkTheme = isAppInDarkTheme() - val scopeCardShape = RoundedCornerShape(16.dp) var restartSystemUi by remember { mutableStateOf(true) } var restartDownloads by remember { mutableStateOf(true) } var restartXmsf by remember { mutableStateOf(true) } @@ -2196,7 +2221,7 @@ private fun RestartScopeDialog( OverlayDialog( show = show, - title = "选择需要重启的进程", + title = textOf("选择需要重启的进程", "Select Processes to Restart"), onDismissRequest = onDismiss, onDismissFinished = {}, renderInRootScaffold = false, @@ -2210,7 +2235,7 @@ private fun RestartScopeDialog( .scrollEndHaptic(), ) { Text( - text = "选择后将依次重启对应进程并刷新岛通知能力。", + text = textOf("选择后将依次重启对应进程并刷新岛通知能力。", "The selected processes will be restarted one by one to refresh Dynamic Island support."), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -2219,38 +2244,21 @@ private fun RestartScopeDialog( Column( modifier = Modifier .fillMaxWidth() - .clip(scopeCardShape) - .background( - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = if (isDarkTheme) 0.22f else 0.12f, - ), - ) - .then( - if (isDarkTheme) { - Modifier.border( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), - scopeCardShape, - ) - } else { - Modifier - }, - ) .padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { ScopeCheckboxRow( - title = "系统界面", + title = textOf("系统界面", "System UI"), subtitle = "com.android.systemui", checked = restartSystemUi, ) { restartSystemUi = !restartSystemUi } ScopeCheckboxRow( - title = "下载管理", + title = textOf("下载管理", "Download Manager"), subtitle = "com.android.providers.downloads", checked = restartDownloads, ) { restartDownloads = !restartDownloads } ScopeCheckboxRow( - title = "小米服务框架", + title = textOf("小米服务框架", "Xiaomi Service Framework"), subtitle = "com.xiaomi.xmsf", checked = restartXmsf, ) { restartXmsf = !restartXmsf } @@ -2268,7 +2276,10 @@ private fun RestartScopeDialog( }, modifier = Modifier.weight(1f), ) { - Text(if (allSelected) "全不选" else "全选", color = MaterialTheme.colorScheme.onBackground) + Text( + if (allSelected) textOf("全不选", "Deselect All") else textOf("全选", "Select All"), + color = MaterialTheme.colorScheme.onBackground, + ) } MiuixButton( onClick = { onConfirm(restartSystemUi, restartDownloads, restartXmsf) }, @@ -2276,7 +2287,7 @@ private fun RestartScopeDialog( colors = MiuixButtonDefaults.buttonColorsPrimary(), ) { Text( - text = "确定", + text = textOf("确定", "Confirm"), color = MiuixTheme.colorScheme.onPrimary, ) } @@ -2338,21 +2349,22 @@ private fun SettingsScreen( ) { val context = LocalContext.current val themeModeOptions = listOf( - "system" to "跟随系统", - "light" to "浅色", - "dark" to "深色", + "system" to textOf("跟随系统", "Follow System"), + "light" to textOf("浅色", "Light"), + "dark" to textOf("深色", "Dark"), ) val selectedThemeIndex = themeModeOptions.indexOfFirst { it.first == state.themeMode }.coerceAtLeast(0) val localeOptions = listOf( - "__system__" to "跟随系统", - "zh" to "中文", + "__system__" to textOf("跟随系统", "Follow System"), + "zh" to textOf("中文", "Chinese"), "en" to "English", "ja" to "日本語", "tr" to "Türkçe", ) val localeValue = state.locale ?: "__system__" val selectedLocaleIndex = localeOptions.indexOfFirst { it.first == localeValue }.coerceAtLeast(0) + val qqCopiedToast = textOf("群号已复制到剪贴板", "Group number copied to clipboard") val contentPadding = LocalContentPadding.current @@ -2369,142 +2381,146 @@ private fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { - SectionTitle("AI 增强") + SectionTitle(textOf("AI 增强", "AI")) SettingsGroupCard { SettingsEntryItem( - title = "AI 通知摘要", - subtitle = if (state.aiEnabled) "已启用 · 点击配置 AI 参数" else "已关闭 · 点击进行配置", + title = textOf("AI 通知摘要", "AI Notification Summary"), + subtitle = if (state.aiEnabled) { + textOf("已启用 · 点击配置 AI 参数", "Enabled · Tap to configure AI") + } else { + textOf("已关闭 · 点击进行配置", "Disabled · Tap to configure") + }, onClick = onOpenAiConfig, ) } } item { - SectionTitle("通知黑名单") + SectionTitle(textOf("通知黑名单", "Notification Blacklist")) SettingsGroupCard { SettingsEntryItem( - title = "通知黑名单", - subtitle = "启动黑名单应用时,停用焦点通知的自动展开功能", + title = textOf("通知黑名单", "Notification Blacklist"), + subtitle = textOf("启动黑名单应用时,停用焦点通知的自动展开功能", "Disable automatic focus expansion when blacklisted apps are opened"), onClick = onOpenBlacklist, ) } } item { - SectionTitle("行为") + SectionTitle(textOf("行为", "Behavior")) SettingsGroupCard { ToggleItem( - "交互触感", - "为开关、滑块和按钮启用 Hyper 定制震感反馈", + textOf("交互触感", "Interaction Haptics"), + textOf("为开关、滑块和按钮启用 Hyper 定制震感反馈", "Enable custom Hyper haptics for switches, sliders, and buttons"), state.interactionHaptics, ) { onToggle(PrefKeys.INTERACTION_HAPTICS, it) } ToggleItem( - "下载管理器暂停后保留焦点通知", - "显示一条通知,点击以继续下载,可能导致状态不同步", + textOf("下载管理器暂停后保留焦点通知", "Keep Focus Notification After Pause"), + textOf("显示一条通知,点击以继续下载,可能导致状态不同步", "Show a notification you can tap to resume downloads; may cause state desync"), state.resumeNotification, ) { onToggle(PrefKeys.RESUME_NOTIFICATION, it) } ToggleItem( - "移除焦点通知白名单", - "允许所有应用发送焦点通知,无需系统授权", + textOf("移除焦点通知白名单", "Remove Focus Notification Whitelist"), + textOf("允许所有应用发送焦点通知,无需系统授权", "Allow all apps to send focus notifications without system approval"), state.unlockAllFocus, ) { onToggle(PrefKeys.UNLOCK_ALL_FOCUS, it) } ToggleItem( - "移除焦点通知签名验证", - "允许所有应用向手表/手环发送焦点通知,跳过签名校验(需 Hook 小米服务框架)", + textOf("移除焦点通知签名验证", "Bypass Focus Signature Check"), + textOf("允许所有应用向手表/手环发送焦点通知,跳过签名校验(需 Hook 小米服务框架)", "Allow all apps to send focus notifications to watches/bands by skipping signature checks (requires hooking Xiaomi Service Framework)"), state.unlockFocusAuth, ) { onToggle(PrefKeys.UNLOCK_FOCUS_AUTH, it) } ToggleItem( - "显示启动欢迎语", - "应用启动时在超级岛显示欢迎信息", + textOf("显示启动欢迎语", "Show Startup Greeting"), + textOf("应用启动时在超级岛显示欢迎信息", "Show a welcome message in Dynamic Island when the app launches"), state.showWelcome, ) { onToggle(PrefKeys.SHOW_WELCOME, it) } ToggleItem( - "隐藏桌面图标", - "隐藏启动器中的应用图标,隐藏后可通过 LSPosed 管理器打开", + textOf("隐藏桌面图标", "Hide Launcher Icon"), + textOf("隐藏启动器中的应用图标,隐藏后可通过 LSPosed 管理器打开", "Hide the launcher icon. After hiding, open the app from LSPosed Manager"), state.hideDesktopIcon, onHideDesktopIcon, ) ToggleItem( - "启动时检查更新", - "启动应用时自动检查是否有新版本", + textOf("启动时检查更新", "Check for Updates on Launch"), + textOf("启动应用时自动检查是否有新版本", "Automatically check for a new version when the app starts"), state.checkUpdateOnLaunch, ) { onToggle(PrefKeys.CHECK_UPDATE_ON_LAUNCH, it) } } } item { - SectionTitle("渠道默认配置") + SectionTitle(textOf("渠道默认配置", "Default Channel Settings")) SettingsGroupCard { ToggleItem( - "初次展开", - "超级岛初次收到通知后是否展开为焦点通知", + textOf("初次展开", "Expand on First Arrival"), + textOf("超级岛初次收到通知后是否展开为焦点通知", "Whether Dynamic Island should expand into a focus notification the first time one arrives"), state.defaultFirstFloat, ) { onToggle(PrefKeys.DEFAULT_FIRST_FLOAT, it) } ToggleItem( - "更新展开", - "超级岛更新后是否展开通知", + textOf("更新展开", "Expand on Update"), + textOf("超级岛更新后是否展开通知", "Whether Dynamic Island should expand when the notification updates"), state.defaultEnableFloat, ) { onToggle(PrefKeys.DEFAULT_ENABLE_FLOAT, it) } ToggleItem( - "消息滚动", - "超级岛消息过长是否滚动显示", + textOf("消息滚动速度", "Marquee Speed"), + "", state.defaultMarquee, ) { onToggle(PrefKeys.DEFAULT_MARQUEE, it) } ToggleItem( - "高亮动态取色", - "开启后默认使用图标自动取色", + textOf("高亮动态取色", "Dynamic Highlight Color"), + textOf("开启后默认使用图标自动取色", "Use icon-based automatic color extraction by default"), state.defaultDynamicHighlightColor, ) { onToggle(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, it) } ToggleItem( - "外圈光效", + textOf("外圈光效", "Outer Glow"), "", state.defaultOuterGlow, ) { onToggle(PrefKeys.DEFAULT_OUTER_GLOW, it) } ToggleItem( - "焦点通知", - "替换通知为焦点通知(关闭后显示原始通知)", + textOf("焦点通知", "Focus Notification"), + textOf("替换通知为焦点通知(关闭后显示原始通知)", "Replace notifications with focus notifications; disable to keep original notifications"), state.defaultFocusNotif, ) { onToggle(PrefKeys.DEFAULT_FOCUS_NOTIF, it) } ToggleItem( - "锁屏通知复原", - "锁屏时跳过焦点通知处理,保持原始通知隐私行为", + textOf("锁屏通知复原", "Restore on Lock Screen"), + textOf("锁屏时跳过焦点通知处理,保持原始通知隐私行为", "Skip focus notification handling on the lock screen to preserve the original privacy behavior"), state.defaultRestoreLockscreen, ) { onToggle(PrefKeys.DEFAULT_RESTORE_LOCKSCREEN, it) } ToggleItem( - "大岛图标", - "开启后显示超级岛的大图标(小岛不受影响)", + textOf("大岛图标", "Large Island Icon"), + textOf("开启后显示超级岛的大图标(小岛不受影响)", "Show the large icon in Dynamic Island; the small island is unaffected"), state.defaultShowIslandIcon, ) { onToggle(PrefKeys.DEFAULT_SHOW_ISLAND_ICON, it) } ToggleItem( - "状态栏图标", - "焦点通知打开时,是否强制保留状态栏小图标", + textOf("状态栏图标", "Status Bar Icon"), + textOf("焦点通知打开时,是否强制保留状态栏小图标", "Force the small status bar icon to remain visible when focus notifications are enabled"), state.defaultPreserveSmallIcon, ) { onToggle(PrefKeys.DEFAULT_PRESERVE_SMALL_ICON, it) } } } item { - SectionTitle("外观") + SectionTitle(textOf("外观", "Appearance")) SettingsGroupCard { ToggleItem( - "使用应用图标", - "下载管理器通知使用应用图标", + textOf("使用应用图标", "Use App Icon"), + textOf("下载管理器通知使用应用图标", "Use the app icon for Download Manager notifications"), state.useHookAppIcon, ) { onToggle(PrefKeys.USE_HOOK_APP_ICON, it) } ToggleItem( - "图标圆角", - "为通知图标添加圆角效果", + textOf("图标圆角", "Rounded Icons"), + textOf("为通知图标添加圆角效果", "Apply rounded corners to notification icons"), state.roundIcon, ) { onToggle(PrefKeys.ROUND_ICON, it) } ToggleItem( - "悬浮底部导航栏", + textOf("悬浮底部导航栏", "Floating Bottom Navigation"), "", state.useFloatingNavigationBar, ) { onToggle(PrefKeys.USE_FLOATING_NAVIGATION_BAR, it) } SliderItem( - title = "消息滚动", - subtitle = "滚动速度", - valueText = "${state.marqueeSpeed} px/s", + title = textOf("消息滚动速度", "Marquee Speed"), + subtitle = "", + valueText = "${state.marqueeSpeed} px/s", value = state.marqueeSpeed.toFloat(), defaultValue = DEFAULT_MARQUEE_SPEED.toFloat(), valueRange = 20f..500f, @@ -2513,7 +2529,7 @@ private fun SettingsScreen( onResetToDefault = { onMarqueeSpeed(DEFAULT_MARQUEE_SPEED) }, ) ToggleSliderItem( - "修改超级岛最大宽度", + textOf("修改超级岛最大宽度", "Adjust Dynamic Island Max Width"), "", state.bigIslandMaxWidthEnabled, valueText = "${state.bigIslandMaxWidth} dp", @@ -2526,7 +2542,7 @@ private fun SettingsScreen( onResetToDefault = { onBigIslandWidth(DEFAULT_BIG_ISLAND_MAX_WIDTH) }, ) MiuixOverlayDropdownPreference( - title = "颜色模式", + title = textOf("颜色模式", "Color Mode"), items = themeModeOptions.map { it.second }, selectedIndex = selectedThemeIndex, renderInRootScaffold = false, @@ -2536,7 +2552,7 @@ private fun SettingsScreen( }, ) MiuixOverlayDropdownPreference( - title = "语言", + title = textOf("语言", "Language"), items = localeOptions.map { it.second }, selectedIndex = selectedLocaleIndex, renderInRootScaffold = false, @@ -2549,27 +2565,43 @@ private fun SettingsScreen( } item { - SectionTitle("配置") + SectionTitle(textOf("配置", "Configuration")) SettingsGroupCard { - SettingsEntryItem("导出到文件", "将配置保存为 JSON 文件", onExportToFile) - SettingsEntryItem("导出到剪贴板", "将配置复制为 JSON 文本", onExportToClipboard) - SettingsEntryItem("从文件导入", "从 JSON 文件恢复配置", onPickImportFile) - SettingsEntryItem("从剪贴板导入", "从剪贴板中的 JSON 文本恢复配置", onImportFromClipboard) + SettingsEntryItem( + textOf("导出到文件", "Export to File"), + textOf("将配置保存为 JSON 文件", "Save the configuration as a JSON file"), + onExportToFile, + ) + SettingsEntryItem( + textOf("导出到剪贴板", "Export to Clipboard"), + textOf("将配置复制为 JSON 文本", "Copy the configuration as JSON text"), + onExportToClipboard, + ) + SettingsEntryItem( + textOf("从文件导入", "Import from File"), + textOf("从 JSON 文件恢复配置", "Restore the configuration from a JSON file"), + onPickImportFile, + ) + SettingsEntryItem( + textOf("从剪贴板导入", "Import from Clipboard"), + textOf("从剪贴板中的 JSON 文本恢复配置", "Restore the configuration from JSON text in the clipboard"), + onImportFromClipboard, + ) } } item { - SectionTitle("关于") + SectionTitle(textOf("关于", "About")) SettingsGroupCard { - SettingsEntryItem("检查更新", "", onCheckUpdate) + SettingsEntryItem(textOf("检查更新", "Check for Updates"), "", onCheckUpdate) SettingsEntryItem("GitHub", "1812z/HyperIsland", onOpenGithub) SettingsEntryItem( - title = "QQ 交流群", + title = textOf("QQ 交流群", "QQ Group"), subtitle = QQ_GROUP_NUMBER, onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("qq_group", QQ_GROUP_NUMBER)) - Toast.makeText(context, "群号已复制到剪贴板", Toast.LENGTH_SHORT).show() + Toast.makeText(context, qqCopiedToast, Toast.LENGTH_SHORT).show() }, ) } @@ -2673,6 +2705,7 @@ private fun MainActivityPreview() { topBar = { MiuixTopAppBar( title = screenTitle( + UiLanguage.Chinese, when (selectedRoute) { "apps" -> AppScreen.Apps "settings" -> AppScreen.Settings @@ -2873,13 +2906,9 @@ private fun SettingsEntryItem(title: String, subtitle: String, onClick: () -> Un @Composable private fun SectionTitle(title: String) { - val isDarkTheme = isAppInDarkTheme() - Text( + MiuixSmallTitle( text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isDarkTheme) 0.92f else 0.88f), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + insideMargin = PaddingValues(horizontal = 12.dp, vertical = 2.dp), ) } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/UiLocalization.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/UiLocalization.kt new file mode 100644 index 00000000..f931070b --- /dev/null +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/UiLocalization.kt @@ -0,0 +1,37 @@ +package io.github.hyperisland.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration + +enum class UiLanguage { + Chinese, + English, +} + +val LocalUiLanguage = compositionLocalOf { UiLanguage.Chinese } + +@Composable +fun rememberUiLanguage(localeTag: String?): UiLanguage { + val configuration = LocalConfiguration.current + val systemLanguage = configuration.locales[0]?.language.orEmpty() + return remember(localeTag, systemLanguage) { + when (localeTag ?: systemLanguage) { + "en" -> UiLanguage.English + else -> UiLanguage.Chinese + } + } +} + +@Composable +fun textOf(chinese: String, english: String): String { + return textOf(LocalUiLanguage.current, chinese, english) +} + +fun textOf(language: UiLanguage, chinese: String, english: String): String { + return when (language) { + UiLanguage.English -> english + UiLanguage.Chinese -> chinese + } +} diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt index a07b2b3b..1afe8e52 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt @@ -210,6 +210,18 @@ class AppAdaptationRepository(private val context: Context) { "pref_channel_outer_glow_${packageName}_$channelId", "default", ) ?: "default", + outEffectColor = prefs.getString( + "pref_channel_out_effect_color_${packageName}_$channelId", + "", + ) ?: "", + focusCustom = prefs.getString( + "pref_channel_focus_custom_${packageName}_$channelId", + "", + ) ?: "", + islandCustom = prefs.getString( + "pref_channel_island_custom_${packageName}_$channelId", + "", + ) ?: "", ) } @@ -232,9 +244,18 @@ class AppAdaptationRepository(private val context: Context) { "show_left_narrow_font" -> "pref_channel_show_left_narrow_font_${packageName}_$channelId" "show_right_narrow_font" -> "pref_channel_show_right_narrow_font_${packageName}_$channelId" "outer_glow" -> "pref_channel_outer_glow_${packageName}_$channelId" + "out_effect_color" -> "pref_channel_out_effect_color_${packageName}_$channelId" + "focus_custom" -> "pref_channel_focus_custom_${packageName}_$channelId" + "island_custom" -> "pref_channel_island_custom_${packageName}_$channelId" else -> return } - if (setting == "highlight_color" && value.isBlank()) { + if ( + (setting == "highlight_color" || + setting == "out_effect_color" || + setting == "focus_custom" || + setting == "island_custom") && + value.isBlank() + ) { prefs.edit().remove(key).apply() } else { prefs.edit().putString(key, value).apply() @@ -270,9 +291,18 @@ class AppAdaptationRepository(private val context: Context) { "show_left_narrow_font" -> "pref_channel_show_left_narrow_font_${packageName}_$channelId" "show_right_narrow_font" -> "pref_channel_show_right_narrow_font_${packageName}_$channelId" "outer_glow" -> "pref_channel_outer_glow_${packageName}_$channelId" + "out_effect_color" -> "pref_channel_out_effect_color_${packageName}_$channelId" + "focus_custom" -> "pref_channel_focus_custom_${packageName}_$channelId" + "island_custom" -> "pref_channel_island_custom_${packageName}_$channelId" else -> null } ?: return@forEach - if (setting == "highlight_color" && value.isBlank()) { + if ( + (setting == "highlight_color" || + setting == "out_effect_color" || + setting == "focus_custom" || + setting == "island_custom") && + value.isBlank() + ) { editor.remove(key) } else { editor.putString(key, value) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt index 2f56c9bb..1392a9de 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt @@ -39,4 +39,7 @@ data class ChannelExtraSettings( val showLeftNarrowFont: String = "off", val showRightNarrowFont: String = "off", val outerGlow: String = "default", + val outEffectColor: String = "", + val focusCustom: String = "", + val islandCustom: String = "", ) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index 6f5a11c4..3d683d0d 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -137,6 +137,9 @@ class AppChannelsViewModel( "show_left_narrow_font" -> current.copy(showLeftNarrowFont = value) "show_right_narrow_font" -> current.copy(showRightNarrowFont = value) "outer_glow" -> current.copy(outerGlow = value) + "out_effect_color" -> current.copy(outEffectColor = value) + "focus_custom" -> current.copy(focusCustom = value) + "island_custom" -> current.copy(islandCustom = value) else -> return } repo.setChannelSetting(packageName, channelId, setting, value) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 4f63028f..7819f5f7 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -3,6 +3,7 @@ package io.github.hyperisland.ui.app import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -58,17 +59,19 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import io.github.hyperisland.ui.FaGlyph import io.github.hyperisland.ui.FaIcon import io.github.hyperisland.ui.isAppInDarkTheme +import io.github.hyperisland.ui.textOf import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults import top.yukonga.miuix.kmp.basic.Card as MiuixCard import top.yukonga.miuix.kmp.basic.Checkbox as MiuixCheckbox -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator as MiuixInfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.basic.ColorPalette as MiuixColorPalette import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.ArrowRight @@ -258,6 +261,10 @@ fun AppsScreen( val contentPadding = io.github.hyperisland.ui.LocalContentPadding.current val topPadding = contentPadding.calculateTopPadding() + val listContentPadding = PaddingValues( + top = topPadding + 12.dp, + bottom = contentPadding.calculateBottomPadding(), + ) val listTranslationY = if (canPullToRefresh) -topPadding else 0.dp val listContent: @Composable () -> Unit = { @@ -270,51 +277,40 @@ fun AppsScreen( } .scrollEndHaptic(), state = appListState, - contentPadding = contentPadding, + contentPadding = listContentPadding, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (state.loading && state.apps.isEmpty()) { + state.error?.let { item { - Box( - modifier = Modifier.fillMaxWidth().height(200.dp), - contentAlignment = Alignment.Center, - ) { - MiuixCircularProgressIndicator() - } - } - } else { - state.error?.let { - item { - Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) - } - } - items(filtered, key = { it.packageName }) { app -> - val enabled = state.enabledPackages.contains(app.packageName) - val selected = state.selectedPackages.contains(app.packageName) - AppItemRow( - app = app, - enabled = enabled, - onEnabledChange = { enabledValue -> onAppEnabledChange(app.packageName, enabledValue) }, - onClick = { - if (selectionMode) { - onAppSelectedChange(app.packageName) - } else { - onOpenAppChannels(app.packageName) - } - }, - selectionMode = selectionMode, - selected = selected, - onSelectedChange = { onAppSelectedChange(app.packageName) }, - ) + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } } + items(filtered, key = { it.packageName }) { app -> + val enabled = state.enabledPackages.contains(app.packageName) + val selected = state.selectedPackages.contains(app.packageName) + AppItemRow( + app = app, + enabled = enabled, + onEnabledChange = { enabledValue -> onAppEnabledChange(app.packageName, enabledValue) }, + onClick = { + if (selectionMode) { + onAppSelectedChange(app.packageName) + } else { + onOpenAppChannels(app.packageName) + } + }, + selectionMode = selectionMode, + selected = selected, + onSelectedChange = { onAppSelectedChange(app.packageName) }, + ) + } } } Box(modifier = modifier.fillMaxSize()) { if (canPullToRefresh) { MiuixPullToRefresh( - isRefreshing = state.loading, + isRefreshing = state.loading && state.apps.isNotEmpty(), onRefresh = onRefresh, modifier = Modifier .fillMaxSize() @@ -324,17 +320,35 @@ fun AppsScreen( }, pullToRefreshState = pullToRefreshState, topAppBarScrollBehavior = topAppBarScrollBehavior, - refreshTexts = listOf("下拉刷新", "松开刷新", "正在刷新..."), + refreshTexts = listOf( + textOf("下拉刷新", "Pull to Refresh"), + textOf("松开刷新", "Release to Refresh"), + textOf("正在刷新...", "Refreshing..."), + ), ) { listContent() } } else { listContent() } + if (state.loading && state.apps.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + MiuixInfiniteProgressIndicator( + modifier = Modifier.size(72.dp), + ) + } + } } if (showBatchDialog) { BatchApplyDialog( - title = if (batchForSelected || selectionMode) "批量应用到已选应用的渠道" else "批量应用到已启用应用", + title = if (batchForSelected || selectionMode) { + textOf("批量应用到已选应用的渠道", "Batch Apply to Selected Apps") + } else { + textOf("批量应用到已启用应用", "Batch Apply to Enabled Apps") + }, onDismiss = { showBatchDialog = false }, onApply = { settings -> showBatchDialog = false @@ -426,7 +440,7 @@ fun AppChannelsScreen( .height(200.dp), contentAlignment = Alignment.Center, ) { - MiuixCircularProgressIndicator() + MiuixInfiniteProgressIndicator() } } return@LazyColumn @@ -441,7 +455,7 @@ fun AppChannelsScreen( ) { Text(it, color = MaterialTheme.colorScheme.error) MiuixButton(onClick = onRefresh) { - Text("重试", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("重试", "Retry"), color = MaterialTheme.colorScheme.onBackground) } } } @@ -452,7 +466,7 @@ fun AppChannelsScreen( item { MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Text( - text = "请先开启应用总开关后再配置通知渠道", + text = textOf("请先开启应用总开关后再配置通知渠道", "Enable the app switch before configuring notification channels"), modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -472,12 +486,12 @@ fun AppChannelsScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - "未读取到通知渠道", + textOf("未读取到通知渠道", "No notification channels found"), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) MiuixButton(onClick = onRefresh) { - Text("刷新", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("刷新", "Refresh"), color = MaterialTheme.colorScheme.onBackground) } } } @@ -504,7 +518,7 @@ fun AppChannelsScreen( if (showBatchDialog) { BatchApplyDialog( - title = "批量应用到已启用渠道", + title = textOf("批量应用到已启用渠道", "Batch Apply to Enabled Channels"), onDismiss = { showBatchDialog = false }, onApply = { settings -> showBatchDialog = false @@ -535,7 +549,7 @@ fun ChannelSettingsScreen( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - MiuixCircularProgressIndicator() + MiuixInfiniteProgressIndicator() } return } @@ -549,7 +563,7 @@ fun ChannelSettingsScreen( ) { Text(state.error, color = MaterialTheme.colorScheme.error) MiuixButton(onClick = onRefresh) { - Text("重试", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("重试", "Retry"), color = MaterialTheme.colorScheme.onBackground) } } return @@ -562,9 +576,9 @@ fun ChannelSettingsScreen( .padding(horizontal = 16.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("未找到该通知渠道", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(textOf("未找到该通知渠道", "Notification channel not found"), color = MaterialTheme.colorScheme.onSurfaceVariant) MiuixButton(onClick = onRefresh) { - Text("刷新", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("刷新", "Refresh"), color = MaterialTheme.colorScheme.onBackground) } } return @@ -660,7 +674,7 @@ private fun ChannelListItem( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "重要性: ${channel.importance}", + text = "${textOf("重要性", "Importance")}: ${channel.importance}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -676,7 +690,7 @@ private fun ChannelListItem( MiuixIconButton(onClick = onOpenSettings, enabled = enabled) { Icon( imageVector = MiuixIcons.Regular.Settings, - contentDescription = "渠道设置", + contentDescription = textOf("渠道设置", "Channel Settings"), tint = if (enabled) { MaterialTheme.colorScheme.onSurfaceVariant } else { @@ -691,7 +705,7 @@ private fun ChannelListItem( } if (channel.description.isNotBlank()) { Text( - "描述: ${channel.description}", + "${textOf("描述", "Description")}: ${channel.description}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -711,24 +725,26 @@ private fun ChannelSettingsContent( onSetSetting: (String, String) -> Unit, onSetHighlightColor: (String) -> Unit, ) { + val uiLanguage = io.github.hyperisland.ui.LocalUiLanguage.current + fun triStateOptions(defaultOn: Boolean): List> = listOf( - "default" to if (defaultOn) "默认(开启)" else "默认(关闭)", - "on" to "开启", - "off" to "关闭", + "default" to if (defaultOn) textOf(uiLanguage, "默认(开启)", "Default (On)") else textOf(uiLanguage, "默认(关闭)", "Default (Off)"), + "on" to textOf(uiLanguage, "开启", "On"), + "off" to textOf(uiLanguage, "关闭", "Off"), ) val templateOptions = listOf( - "generic_progress" to "下载", - "notification_island" to "通知超级岛", - "notification_island_lite" to "通知超级岛 Lite", - "download_lite" to "下载 Lite", - "ai_notification_island" to "AI 通知超级岛", + "generic_progress" to textOf("下载", "Download"), + "notification_island" to textOf("通知超级岛", "Notification Island"), + "notification_island_lite" to textOf("通知超级岛 | 精简", "Notification Island|Lite"), + "download_lite" to textOf("下载|Lite", "Download|Lite"), + "ai_notification_island" to textOf("AI 通知超级岛", "AI Notification Island"), ) val iconModeOptions = listOf( - "auto" to "自动", - "notif_small" to "通知小图标", - "notif_large" to "通知大图标", - "app_icon" to "应用图标", + "auto" to textOf("自动", "Auto"), + "notif_small" to textOf("通知小图标", "Small Notification Icon"), + "notif_large" to textOf("通知大图标", "Large Notification Icon"), + "app_icon" to textOf("应用图标", "App Icon"), ) val showIslandIconOptions = triStateOptions(defaultOn = true) val firstFloatOptions = triStateOptions(defaultOn = false) @@ -738,90 +754,107 @@ private fun ChannelSettingsContent( val preserveStatusBarOptions = triStateOptions(defaultOn = false) val restoreLockscreenOptions = triStateOptions(defaultOn = false) val dynamicHighlightOptions = listOf( - "default" to "默认(关闭)", - "on" to "开启", - "off" to "关闭", - "dark" to "暗", - "darker" to "更暗", + "default" to textOf("默认(关闭)", "Default (Off)"), + "on" to textOf("开启", "On"), + "off" to textOf("关闭", "Off"), + "dark" to textOf("暗", "Dark"), + "darker" to textOf("更暗", "Darker"), ) val outerGlowOptions = triStateOptions(defaultOn = false) val rendererOptions = listOf( - "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", - "image_text_with_buttons_4_wrap" to "封面信息样式", - "image_text_with_right_text_button" to "图文右侧文本按钮", + "image_text_with_buttons_4" to textOf("新图文组件 + 底部文本按钮", "Image+Text+Bottom Text Buttons"), + "image_text_with_buttons_4_wrap" to textOf("封面组件 + 自动换行", "Cover Info+Auto Wrap"), + "image_text_with_right_text_button" to textOf("新图文组件 + 右侧文本按钮", "Image+Text+Right Text Button"), ) var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .padding(vertical = 0.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - ChannelSectionTitle("模板") - ChannelSectionCard { + ChannelSectionTitle(textOf("模板", "Template")) + ChannelSectionCard( + verticalPadding = 0.dp, + itemSpacing = 0.dp, + ) { SettingsDropdownRow( - title = "模板", + title = textOf("模板", "Template"), options = templateOptions, selectedValue = template, enabled = true, largeText = true, onValueChange = onSetTemplate, ) - SettingsDropdownRow("样式", rendererOptions, extras.renderer, true, largeText = true) { + SettingsDropdownRow(textOf("样式", "Style"), rendererOptions, extras.renderer, true, largeText = true) { onSetSetting("renderer", it) } } - ChannelSectionTitle("岛") - ChannelSectionCard { - SettingsDropdownRow("超级岛图标", iconModeOptions, extras.icon, true, largeText = true) { onSetSetting("icon", it) } - SettingsDropdownRow("大岛图标", showIslandIconOptions, extras.showIslandIcon, true, largeText = true) { + ChannelSectionTitle(textOf("岛", "Island")) + ChannelSectionCard( + verticalPadding = 0.dp, + itemSpacing = 0.dp, + ) { + SettingsDropdownRow(textOf("超级岛图标", "Dynamic Island Icon"), iconModeOptions, extras.icon, true, largeText = true) { onSetSetting("icon", it) } + SettingsDropdownRow(textOf("大岛图标", "Large Island Icon"), showIslandIconOptions, extras.showIslandIcon, true, largeText = true) { onSetSetting("show_island_icon", it) } - SettingsDropdownRow("初次展开", firstFloatOptions, extras.firstFloat, true, largeText = true) { + SettingsDropdownRow(textOf("初次展开", "Expand on First Arrival"), firstFloatOptions, extras.firstFloat, true, largeText = true) { onSetSetting("first_float", it) } - SettingsDropdownRow("更新展开", enableFloatOptions, extras.enableFloat, true, largeText = true) { + SettingsDropdownRow(textOf("更新展开", "Expand on Update"), enableFloatOptions, extras.enableFloat, true, largeText = true) { onSetSetting("enable_float", it) } - SettingsDropdownRow("消息滚动", marqueeOptions, extras.marquee, true, largeText = true) { + SettingsDropdownRow(textOf("消息滚动速度", "Marquee Speed"), marqueeOptions, extras.marquee, true, largeText = true) { onSetSetting("marquee", it) } InputDialogRow( - title = "自动消失时长", - subtitle = "点击后在对话框中输入 1-30 秒", + title = textOf("自动消失时长", "Auto Dismiss Timeout"), + subtitle = textOf("点击后在对话框中输入 1-30 秒", "Tap to enter a value between 1 and 30 seconds"), value = timeout, emptyValueText = "5", - dialogTitle = "修改自动消失时长", - dialogDescription = "值应该大于等于 1 并小于等于 30", + dialogTitle = textOf("修改自动消失时长", "Edit Auto Dismiss Timeout"), + dialogDescription = textOf("值应该大于等于 1 并小于等于 30", "The value must be between 1 and 30"), onConfirm = { onSetTimeout(it.trim()) }, ) InputDialogRow( - title = "高亮颜色", - subtitle = "点击后输入 #RRGGBB,留空可清空", + title = textOf("高亮颜色", "Highlight Color"), + subtitle = textOf("点击后输入 #RRGGBB,留空可清空", "Tap to enter #RRGGBB, or leave blank to clear"), value = highlightDraft, - emptyValueText = "未设置", - dialogTitle = "修改高亮颜色", - dialogDescription = "请输入 #RRGGBB 格式,留空可清空当前颜色", + emptyValueText = textOf("未设置", "Not Set"), + dialogTitle = textOf("修改高亮颜色", "Edit Highlight Color"), + dialogDescription = textOf("请输入 #RRGGBB 格式,留空可清空当前颜色", "Enter a color in #RRGGBB format, or leave blank to clear"), + enableColorPalette = true, onConfirm = { val next = it.trim() highlightDraft = next onSetHighlightColor(next) }, ) + InputDialogRow( + title = textOf("外圈光效颜色", "Outer Glow Color"), + subtitle = textOf("点击后输入 #RRGGBB,留空可清空", "Tap to enter #RRGGBB, or leave blank to clear"), + value = extras.outEffectColor, + emptyValueText = textOf("未设置", "Not Set"), + dialogTitle = textOf("修改外圈光效颜色", "Edit Outer Glow Color"), + dialogDescription = textOf("请输入 #RRGGBB 格式,留空可清空当前颜色", "Enter a color in #RRGGBB format, or leave blank to clear"), + enableColorPalette = true, + onConfirm = { onSetSetting("out_effect_color", it.trim()) }, + ) SwitchSettingRow( - title = "左侧高亮", + title = textOf("左侧高亮", "Left Highlight"), checked = extras.showLeftHighlight == "on", onCheckedChange = { onSetSetting("show_left_highlight", if (it) "on" else "off") }, ) SwitchSettingRow( - title = "右侧高亮", + title = textOf("右侧高亮", "Right Highlight"), checked = extras.showRightHighlight == "on", onCheckedChange = { onSetSetting("show_right_highlight", if (it) "on" else "off") }, ) SettingsDropdownRow( - "高亮动态取色", + textOf("高亮动态取色", "Dynamic Highlight Color"), dynamicHighlightOptions, extras.dynamicHighlightColor, true, @@ -830,36 +863,57 @@ private fun ChannelSettingsContent( onSetSetting("dynamic_highlight_color", it) } SwitchSettingRow( - title = "左侧窄字体", + title = textOf("左侧窄字体", "Left Narrow Font"), checked = extras.showLeftNarrowFont == "on", onCheckedChange = { onSetSetting("show_left_narrow_font", if (it) "on" else "off") }, ) SwitchSettingRow( - title = "右侧窄字体", + title = textOf("右侧窄字体", "Right Narrow Font"), checked = extras.showRightNarrowFont == "on", onCheckedChange = { onSetSetting("show_right_narrow_font", if (it) "on" else "off") }, ) } - ChannelSectionTitle("焦点通知") - ChannelSectionCard { - SettingsDropdownRow("焦点图标", iconModeOptions, extras.focusIcon, true, largeText = true) { + ChannelSectionTitle(textOf("焦点通知", "Focus Notification")) + ChannelSectionCard( + verticalPadding = 0.dp, + itemSpacing = 0.dp, + ) { + SettingsDropdownRow(textOf("焦点图标", "Focus Icon"), iconModeOptions, extras.focusIcon, true, largeText = true) { onSetSetting("focus_icon", it) } - SettingsDropdownRow("焦点通知", focusOptions, extras.focus, true, largeText = true) { onSetSetting("focus", it) } + SettingsDropdownRow(textOf("焦点通知", "Focus Notification"), focusOptions, extras.focus, true, largeText = true) { onSetSetting("focus", it) } SettingsDropdownRow( - "状态栏图标", + textOf("状态栏图标", "Status Bar Icon"), preserveStatusBarOptions, extras.preserveSmallIcon, extras.focus != "off", largeText = true, ) { onSetSetting("preserve_small_icon", it) } - SettingsDropdownRow("锁屏通知恢复", restoreLockscreenOptions, extras.restoreLockscreen, true, largeText = true) { + SettingsDropdownRow(textOf("锁屏通知恢复", "Restore on Lock Screen"), restoreLockscreenOptions, extras.restoreLockscreen, true, largeText = true) { onSetSetting("restore_lockscreen", it) } - SettingsDropdownRow("外圈光效", outerGlowOptions, extras.outerGlow, true, largeText = true) { + SettingsDropdownRow(textOf("外圈光效", "Outer Glow"), outerGlowOptions, extras.outerGlow, true, largeText = true) { onSetSetting("outer_glow", it) } + InputDialogRow( + title = textOf("焦点表达式自定义", "Custom Focus Expression"), + subtitle = textOf("点击后输入 JSON,留空可清空", "Tap to enter JSON, or leave blank to clear"), + value = extras.focusCustom, + emptyValueText = textOf("未设置", "Not Set"), + dialogTitle = textOf("修改焦点表达式自定义", "Edit Custom Focus Expression"), + dialogDescription = textOf("请输入 JSON 字符串,留空可清空", "Enter a JSON string, or leave blank to clear"), + onConfirm = { onSetSetting("focus_custom", it.trim()) }, + ) + InputDialogRow( + title = textOf("岛表达式自定义", "Custom Island Expression"), + subtitle = textOf("点击后输入 JSON,留空可清空", "Tap to enter JSON, or leave blank to clear"), + value = extras.islandCustom, + emptyValueText = textOf("未设置", "Not Set"), + dialogTitle = textOf("修改岛表达式自定义", "Edit Custom Island Expression"), + dialogDescription = textOf("请输入 JSON 字符串,留空可清空", "Enter a JSON string, or leave blank to clear"), + onConfirm = { onSetSetting("island_custom", it.trim()) }, + ) } } } @@ -873,10 +927,49 @@ private fun ChannelSectionTitle(title: String) { } @Composable -private fun ChannelSectionCard(content: @Composable () -> Unit) { +private fun ChannelSectionCard( + verticalPadding: Dp = 8.dp, + itemSpacing: Dp = 6.dp, + content: @Composable () -> Unit, +) { MiuixCard(modifier = sectionCardModifier(Modifier.fillMaxWidth())) { Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + content() + } + } +} + +@Composable +private fun BatchSheetSectionCard(content: @Composable () -> Unit) { + val isDarkTheme = isAppInDarkTheme() + val shape = RoundedCornerShape(18.dp) + val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = if (isDarkTheme) 0.42f else 0.65f, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(containerColor) + .then( + if (isDarkTheme) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.36f), + shape = shape, + ) + } else { + Modifier + }, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { content() @@ -916,6 +1009,7 @@ private fun InputDialogRow( emptyValueText: String, dialogTitle: String, dialogDescription: String, + enableColorPalette: Boolean = false, onConfirm: (String) -> Unit, ) { var showDialog by remember { mutableStateOf(false) } @@ -1012,6 +1106,15 @@ private fun InputDialogRow( .fillMaxWidth() .focusRequester(inputFocusRequester), ) + if (enableColorPalette) { + MiuixColorPalette( + color = parseHexColorOrNull(draft) ?: MaterialTheme.colorScheme.primary, + onColorChanged = { color -> + draft = colorToHexRgb(color) + }, + modifier = Modifier.fillMaxWidth(), + ) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -1069,15 +1172,15 @@ private fun BatchApplyDialog( noChange to "不更改", "generic_progress" to "下载", "notification_island" to "通知超级岛", - "notification_island_lite" to "通知超级岛 Lite", - "download_lite" to "下载 Lite", + "notification_island_lite" to "通知超级岛 | 精简", + "download_lite" to "下载|Lite", "ai_notification_island" to "AI 通知超级岛", ) val rendererOptions = listOf( noChange to "不更改", "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", - "image_text_with_buttons_4_wrap" to "封面信息样式", - "image_text_with_right_text_button" to "图文右侧文本按钮", + "image_text_with_buttons_4_wrap" to "封面组件 + 自动换行", + "image_text_with_right_text_button" to "新图文组件 + 右侧文本按钮", ) val dynamicHighlightOptions = listOf( noChange to "不更改", @@ -1107,11 +1210,15 @@ private fun BatchApplyDialog( var showLeftNarrowFont by remember { mutableStateOf(noChange) } var showRightNarrowFont by remember { mutableStateOf(noChange) } var highlightColor by remember { mutableStateOf("") } + var outEffectColor by remember { mutableStateOf("") } + var focusCustom by remember { mutableStateOf("") } + var islandCustom by remember { mutableStateOf("") } OverlayBottomSheet( show = true, title = title, onDismissRequest = onDismiss, onDismissFinished = {}, + enableNestedScroll = false, startAction = { MiuixIconButton(onClick = onDismiss) { FaIcon( @@ -1154,6 +1261,15 @@ private fun BatchApplyDialog( if (highlightColor.trim().isNotEmpty()) { settings["highlight_color"] = highlightColor.trim() } + if (outEffectColor.trim().isNotEmpty()) { + settings["out_effect_color"] = outEffectColor.trim() + } + if (focusCustom.trim().isNotEmpty()) { + settings["focus_custom"] = focusCustom.trim() + } + if (islandCustom.trim().isNotEmpty()) { + settings["island_custom"] = islandCustom.trim() + } onApply(settings) }, @@ -1175,13 +1291,13 @@ private fun BatchApplyDialog( .scrollEndHaptic(), ) { ChannelSectionTitle("模板") - ChannelSectionCard { + BatchSheetSectionCard { SettingsDropdownRow("模板", templateOptions, template, true, largeText = true) { template = it } SettingsDropdownRow("样式", rendererOptions, renderer, true, largeText = true) { renderer = it } } ChannelSectionTitle("岛") - ChannelSectionCard { + BatchSheetSectionCard { SettingsDropdownRow("超级岛图标", iconModeOptions, icon, true, largeText = true) { icon = it } SettingsDropdownRow("大岛图标", triStateOptions, showIslandIcon, true, largeText = true) { showIslandIcon = it @@ -1192,7 +1308,7 @@ private fun BatchApplyDialog( SettingsDropdownRow("更新展开", triStateOptions, enableFloat, true, largeText = true) { enableFloat = it } - SettingsDropdownRow("消息滚动", triStateOptions, marquee, true, largeText = true) { + SettingsDropdownRow("消息滚动速度", triStateOptions, marquee, true, largeText = true) { marquee = it } InputDialogRow( @@ -1211,8 +1327,19 @@ private fun BatchApplyDialog( emptyValueText = "不更改", dialogTitle = "修改高亮颜色", dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", + enableColorPalette = true, onConfirm = { highlightColor = it.trim() }, ) + InputDialogRow( + title = "外圈光效颜色", + subtitle = "点击后输入 #RRGGBB,留空表示不更改", + value = outEffectColor, + emptyValueText = "不更改", + dialogTitle = "修改外圈光效颜色", + dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", + enableColorPalette = true, + onConfirm = { outEffectColor = it.trim() }, + ) SettingsDropdownRow("高亮动态取色", dynamicHighlightOptions, dynamicHighlightColor, true, largeText = true) { dynamicHighlightColor = it } @@ -1231,10 +1358,8 @@ private fun BatchApplyDialog( } ChannelSectionTitle("焦点通知") - ChannelSectionCard { - SettingsDropdownRow("焦点图标", iconModeOptions, focusIcon, true, largeText = true) { - focusIcon = it - } + BatchSheetSectionCard { + SettingsDropdownRow("焦点图标", iconModeOptions, focusIcon, true, largeText = true) { focusIcon = it } SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { focus = it } SettingsDropdownRow("状态栏图标", triStateOptions, preserveSmallIcon, true, largeText = true) { preserveSmallIcon = it @@ -1245,12 +1370,44 @@ private fun BatchApplyDialog( SettingsDropdownRow("外圈光效", triStateOptions, outerGlow, true, largeText = true) { outerGlow = it } + InputDialogRow( + title = "焦点表达式自定义", + subtitle = "点击后输入 JSON,留空表示不更改", + value = focusCustom, + emptyValueText = "不更改", + dialogTitle = "修改焦点表达式自定义", + dialogDescription = "请输入 JSON 字符串,留空表示不更改", + onConfirm = { focusCustom = it.trim() }, + ) + InputDialogRow( + title = "岛表达式自定义", + subtitle = "点击后输入 JSON,留空表示不更改", + value = islandCustom, + emptyValueText = "不更改", + dialogTitle = "修改岛表达式自定义", + dialogDescription = "请输入 JSON 字符串,留空表示不更改", + onConfirm = { islandCustom = it.trim() }, + ) } } }, ) } +private fun parseHexColorOrNull(raw: String): Color? { + val text = raw.trim() + if (text.isBlank()) return null + val normalized = if (text.startsWith("#")) text else "#$text" + return runCatching { Color(android.graphics.Color.parseColor(normalized)) }.getOrNull() +} + +private fun colorToHexRgb(color: Color): String { + val red = (color.red * 255f).toInt().coerceIn(0, 255) + val green = (color.green * 255f).toInt().coerceIn(0, 255) + val blue = (color.blue * 255f).toInt().coerceIn(0, 255) + return String.format("#%02X%02X%02X", red, green, blue) +} + @Composable private fun AppItemRow( app: AppItem, diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt index fea392c1..242692e4 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/blacklist/BlacklistScreen.kt @@ -36,7 +36,7 @@ import top.yukonga.miuix.kmp.icon.extended.All import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.Button as MiuixButton import top.yukonga.miuix.kmp.basic.Card as MiuixCard -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator as MiuixInfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.PullToRefresh as MiuixPullToRefresh import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState @@ -105,7 +105,7 @@ fun BlacklistScreen( .height(200.dp), contentAlignment = Alignment.Center, ) { - MiuixCircularProgressIndicator() + MiuixInfiniteProgressIndicator() } } } From b915620c79a521a0f84d140c0f9b84bf340823e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 14:50:46 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E9=80=82=E9=85=8D2.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/app/AppChannelsViewModel.kt | 9 +- .../github/hyperisland/ui/app/AppsScreens.kt | 94 +++++++++++++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index 3d683d0d..c081672a 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -123,7 +123,14 @@ class AppChannelsViewModel( val next = when (setting) { "icon" -> current.copy(icon = value) "focus_icon" -> current.copy(focusIcon = value) - "focus" -> current.copy(focus = value) + "focus" -> { + if (value == "off") { + repo.setChannelSetting(packageName, channelId, "preserve_small_icon", "off") + current.copy(focus = value, preserveSmallIcon = "off") + } else { + current.copy(focus = value) + } + } "preserve_small_icon" -> current.copy(preserveSmallIcon = value) "show_island_icon" -> current.copy(showIslandIcon = value) "first_float" -> current.copy(firstFloat = value) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index 7819f5f7..db21bbad 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -1,5 +1,6 @@ package io.github.hyperisland.ui.app +import android.content.Context import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image @@ -55,7 +56,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import io.github.hyperisland.data.prefs.PrefKeys import io.github.hyperisland.ui.FaGlyph import io.github.hyperisland.ui.FaIcon import io.github.hyperisland.ui.isAppInDarkTheme @@ -726,6 +729,17 @@ private fun ChannelSettingsContent( onSetHighlightColor: (String) -> Unit, ) { val uiLanguage = io.github.hyperisland.ui.LocalUiLanguage.current + val context = LocalContext.current + val defaultDynamicHighlightEnabled = remember(context) { + context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, false) + } + val dynamicHighlightEnabled = remember(extras.dynamicHighlightColor, defaultDynamicHighlightEnabled) { + isDynamicHighlightEnabled(extras.dynamicHighlightColor, defaultDynamicHighlightEnabled) + } + val hasHighlightColor = dynamicHighlightEnabled || extras.highlightColor.trim().isNotEmpty() + val focusDisabled = extras.focus == "off" + val preserveSmallIconValue = if (focusDisabled) "off" else extras.preserveSmallIcon fun triStateOptions(defaultOn: Boolean): List> = listOf( "default" to if (defaultOn) textOf(uiLanguage, "默认(开启)", "Default (On)") else textOf(uiLanguage, "默认(关闭)", "Default (Off)"), @@ -821,12 +835,17 @@ private fun ChannelSettingsContent( ) InputDialogRow( title = textOf("高亮颜色", "Highlight Color"), - subtitle = textOf("点击后输入 #RRGGBB,留空可清空", "Tap to enter #RRGGBB, or leave blank to clear"), + subtitle = if (dynamicHighlightEnabled) { + textOf("动态取色开启时不可编辑", "Disabled while dynamic highlight is enabled") + } else { + textOf("点击后输入 #RRGGBB,留空可清空", "Tap to enter #RRGGBB, or leave blank to clear") + }, value = highlightDraft, emptyValueText = textOf("未设置", "Not Set"), dialogTitle = textOf("修改高亮颜色", "Edit Highlight Color"), dialogDescription = textOf("请输入 #RRGGBB 格式,留空可清空当前颜色", "Enter a color in #RRGGBB format, or leave blank to clear"), enableColorPalette = true, + enabled = !dynamicHighlightEnabled, onConfirm = { val next = it.trim() highlightDraft = next @@ -846,11 +865,13 @@ private fun ChannelSettingsContent( SwitchSettingRow( title = textOf("左侧高亮", "Left Highlight"), checked = extras.showLeftHighlight == "on", + enabled = hasHighlightColor, onCheckedChange = { onSetSetting("show_left_highlight", if (it) "on" else "off") }, ) SwitchSettingRow( title = textOf("右侧高亮", "Right Highlight"), checked = extras.showRightHighlight == "on", + enabled = hasHighlightColor, onCheckedChange = { onSetSetting("show_right_highlight", if (it) "on" else "off") }, ) SettingsDropdownRow( @@ -882,12 +903,17 @@ private fun ChannelSettingsContent( SettingsDropdownRow(textOf("焦点图标", "Focus Icon"), iconModeOptions, extras.focusIcon, true, largeText = true) { onSetSetting("focus_icon", it) } - SettingsDropdownRow(textOf("焦点通知", "Focus Notification"), focusOptions, extras.focus, true, largeText = true) { onSetSetting("focus", it) } + SettingsDropdownRow(textOf("焦点通知", "Focus Notification"), focusOptions, extras.focus, true, largeText = true) { + onSetSetting("focus", it) + if (it == "off" && extras.preserveSmallIcon != "off") { + onSetSetting("preserve_small_icon", "off") + } + } SettingsDropdownRow( textOf("状态栏图标", "Status Bar Icon"), preserveStatusBarOptions, - extras.preserveSmallIcon, - extras.focus != "off", + preserveSmallIconValue, + !focusDisabled, largeText = true, ) { onSetSetting("preserve_small_icon", it) } SettingsDropdownRow(textOf("锁屏通知恢复", "Restore on Lock Screen"), restoreLockscreenOptions, extras.restoreLockscreen, true, largeText = true) { @@ -1010,6 +1036,7 @@ private fun InputDialogRow( dialogTitle: String, dialogDescription: String, enableColorPalette: Boolean = false, + enabled: Boolean = true, onConfirm: (String) -> Unit, ) { var showDialog by remember { mutableStateOf(false) } @@ -1017,15 +1044,15 @@ private fun InputDialogRow( val inputFocusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val displayValue = value.ifBlank { emptyValueText } - val titleColor = MiuixTheme.colorScheme.onBackground - val summaryColor = MiuixTheme.colorScheme.onSurfaceVariantSummary - val valueColor = MiuixTheme.colorScheme.onSurfaceVariantActions + val titleColor = if (enabled) MiuixTheme.colorScheme.onBackground else MiuixTheme.colorScheme.onBackground.copy(alpha = 0.5f) + val summaryColor = if (enabled) MiuixTheme.colorScheme.onSurfaceVariantSummary else MiuixTheme.colorScheme.onSurfaceVariantSummary.copy(alpha = 0.5f) + val valueColor = if (enabled) MiuixTheme.colorScheme.onSurfaceVariantActions else MiuixTheme.colorScheme.onSurfaceVariantActions.copy(alpha = 0.5f) Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 56.dp) - .clickable { + .clickable(enabled = enabled) { draft = value showDialog = true } @@ -1150,6 +1177,11 @@ private fun BatchApplyDialog( onApply: (Map) -> Unit, ) { val noChange = "__NO_CHANGE__" + val context = LocalContext.current + val defaultDynamicHighlightEnabled = remember(context) { + context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, false) + } val triStateOptions = listOf( noChange to "不更改", "default" to "默认", @@ -1213,6 +1245,16 @@ private fun BatchApplyDialog( var outEffectColor by remember { mutableStateOf("") } var focusCustom by remember { mutableStateOf("") } var islandCustom by remember { mutableStateOf("") } + val dynamicHighlightEnabled = remember(dynamicHighlightColor, defaultDynamicHighlightEnabled) { + isDynamicHighlightEnabled( + value = dynamicHighlightColor, + defaultEnabled = defaultDynamicHighlightEnabled, + noChangeValue = noChange, + ) + } + val hasHighlightColor = dynamicHighlightEnabled || highlightColor.trim().isNotEmpty() + val focusDisabled = focus == "off" + val preserveSmallIconValue = if (focusDisabled) "off" else preserveSmallIcon OverlayBottomSheet( show = true, title = title, @@ -1241,7 +1283,7 @@ private fun BatchApplyDialog( putIfChanged("icon", icon) putIfChanged("focus_icon", focusIcon) putIfChanged("focus", focus) - putIfChanged("preserve_small_icon", preserveSmallIcon) + putIfChanged("preserve_small_icon", preserveSmallIconValue) putIfChanged("show_island_icon", showIslandIcon) putIfChanged("first_float", firstFloat) putIfChanged("enable_float", enableFloat) @@ -1322,12 +1364,13 @@ private fun BatchApplyDialog( ) InputDialogRow( title = "高亮颜色", - subtitle = "点击后输入 #RRGGBB,留空表示不更改", + subtitle = if (dynamicHighlightEnabled) "动态取色开启时不可编辑" else "点击后输入 #RRGGBB,留空表示不更改", value = highlightColor, emptyValueText = "不更改", dialogTitle = "修改高亮颜色", dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", enableColorPalette = true, + enabled = !dynamicHighlightEnabled, onConfirm = { highlightColor = it.trim() }, ) InputDialogRow( @@ -1343,10 +1386,10 @@ private fun BatchApplyDialog( SettingsDropdownRow("高亮动态取色", dynamicHighlightOptions, dynamicHighlightColor, true, largeText = true) { dynamicHighlightColor = it } - SettingsDropdownRow("左侧高亮", toggleOptions, showLeftHighlight, true, largeText = true) { + SettingsDropdownRow("左侧高亮", toggleOptions, showLeftHighlight, hasHighlightColor, largeText = true) { showLeftHighlight = it } - SettingsDropdownRow("右侧高亮", toggleOptions, showRightHighlight, true, largeText = true) { + SettingsDropdownRow("右侧高亮", toggleOptions, showRightHighlight, hasHighlightColor, largeText = true) { showRightHighlight = it } SettingsDropdownRow("左侧窄字体", toggleOptions, showLeftNarrowFont, true, largeText = true) { @@ -1360,8 +1403,13 @@ private fun BatchApplyDialog( ChannelSectionTitle("焦点通知") BatchSheetSectionCard { SettingsDropdownRow("焦点图标", iconModeOptions, focusIcon, true, largeText = true) { focusIcon = it } - SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { focus = it } - SettingsDropdownRow("状态栏图标", triStateOptions, preserveSmallIcon, true, largeText = true) { + SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { + focus = it + if (it == "off") { + preserveSmallIcon = "off" + } + } + SettingsDropdownRow("状态栏图标", triStateOptions, preserveSmallIconValue, !focusDisabled, largeText = true) { preserveSmallIcon = it } SettingsDropdownRow("锁屏通知恢复", triStateOptions, restoreLockscreen, true, largeText = true) { @@ -1394,6 +1442,19 @@ private fun BatchApplyDialog( ) } +private fun isDynamicHighlightEnabled( + value: String, + defaultEnabled: Boolean, + noChangeValue: String? = null, +): Boolean { + if (value == noChangeValue) return false + return when (value) { + "default" -> defaultEnabled + "on", "dark", "darker" -> true + else -> false + } +} + private fun parseHexColorOrNull(raw: String): Color? { val text = raw.trim() if (text.isBlank()) return null @@ -1482,6 +1543,7 @@ private fun AppItemRow( private fun SwitchSettingRow( title: String, checked: Boolean, + enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit, ) { Row( @@ -1496,9 +1558,9 @@ private fun SwitchSettingRow( text = title, fontSize = MiuixTheme.textStyles.headline1.fontSize, fontWeight = FontWeight.Medium, - color = MiuixTheme.colorScheme.onBackground, + color = if (enabled) MiuixTheme.colorScheme.onBackground else MiuixTheme.colorScheme.onBackground.copy(alpha = 0.5f), ) - MiuixSwitch(checked = checked, onCheckedChange = onCheckedChange) + MiuixSwitch(checked = checked, onCheckedChange = if (enabled) onCheckedChange else ({ _: Boolean -> })) } } From 0ca833612322257efeb3f2a3d1b73a65e77c85f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 16:37:05 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E9=80=82=E9=85=8D2.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyperisland/ui/app/AppAdaptationRepository.kt | 3 --- .../github/hyperisland/ui/app/AppChannelsUiState.kt | 1 - .../hyperisland/ui/app/AppChannelsViewModel.kt | 1 - .../io/github/hyperisland/ui/app/AppsScreens.kt | 13 ++----------- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt index 1afe8e52..d35b6c32 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppAdaptationRepository.kt @@ -162,7 +162,6 @@ class AppAdaptationRepository(private val context: Context) { fun getChannelExtras(packageName: String, channelId: String): ChannelExtraSettings { return ChannelExtraSettings( icon = prefs.getString("pref_channel_icon_${packageName}_$channelId", "auto") ?: "auto", - focusIcon = prefs.getString("pref_channel_focus_icon_${packageName}_$channelId", "auto") ?: "auto", focus = prefs.getString("pref_channel_focus_${packageName}_$channelId", "default") ?: "default", preserveSmallIcon = prefs.getString( "pref_channel_preserve_small_icon_${packageName}_$channelId", @@ -228,7 +227,6 @@ class AppAdaptationRepository(private val context: Context) { fun setChannelSetting(packageName: String, channelId: String, setting: String, value: String) { val key = when (setting) { "icon" -> "pref_channel_icon_${packageName}_$channelId" - "focus_icon" -> "pref_channel_focus_icon_${packageName}_$channelId" "focus" -> "pref_channel_focus_${packageName}_$channelId" "preserve_small_icon" -> "pref_channel_preserve_small_icon_${packageName}_$channelId" "show_island_icon" -> "pref_channel_show_island_icon_${packageName}_$channelId" @@ -275,7 +273,6 @@ class AppAdaptationRepository(private val context: Context) { "template" -> "pref_channel_template_${packageName}_$channelId" "timeout" -> "pref_channel_timeout_${packageName}_$channelId" "icon" -> "pref_channel_icon_${packageName}_$channelId" - "focus_icon" -> "pref_channel_focus_icon_${packageName}_$channelId" "focus" -> "pref_channel_focus_${packageName}_$channelId" "preserve_small_icon" -> "pref_channel_preserve_small_icon_${packageName}_$channelId" "show_island_icon" -> "pref_channel_show_island_icon_${packageName}_$channelId" diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt index 1392a9de..bc05a83c 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsUiState.kt @@ -23,7 +23,6 @@ data class AppChannelsUiState( data class ChannelExtraSettings( val icon: String = "auto", - val focusIcon: String = "auto", val focus: String = "default", val preserveSmallIcon: String = "default", val showIslandIcon: String = "default", diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index c081672a..88fb2ae9 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -122,7 +122,6 @@ class AppChannelsViewModel( val current = _uiState.value.channelExtras[channelId] ?: return val next = when (setting) { "icon" -> current.copy(icon = value) - "focus_icon" -> current.copy(focusIcon = value) "focus" -> { if (value == "off") { repo.setChannelSetting(packageName, channelId, "preserve_small_icon", "off") diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index db21bbad..b812187f 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -750,8 +750,6 @@ private fun ChannelSettingsContent( val templateOptions = listOf( "generic_progress" to textOf("下载", "Download"), "notification_island" to textOf("通知超级岛", "Notification Island"), - "notification_island_lite" to textOf("通知超级岛 | 精简", "Notification Island|Lite"), - "download_lite" to textOf("下载|Lite", "Download|Lite"), "ai_notification_island" to textOf("AI 通知超级岛", "AI Notification Island"), ) val iconModeOptions = listOf( @@ -779,6 +777,7 @@ private fun ChannelSettingsContent( "image_text_with_buttons_4" to textOf("新图文组件 + 底部文本按钮", "Image+Text+Bottom Text Buttons"), "image_text_with_buttons_4_wrap" to textOf("封面组件 + 自动换行", "Cover Info+Auto Wrap"), "image_text_with_right_text_button" to textOf("新图文组件 + 右侧文本按钮", "Image+Text+Right Text Button"), + "image_text_with_progress" to textOf("IM 图文组件 + 进度条", "IM Chat Info + Progress"), ) var highlightDraft by remember(extras.highlightColor) { mutableStateOf(extras.highlightColor) } @@ -900,9 +899,6 @@ private fun ChannelSettingsContent( verticalPadding = 0.dp, itemSpacing = 0.dp, ) { - SettingsDropdownRow(textOf("焦点图标", "Focus Icon"), iconModeOptions, extras.focusIcon, true, largeText = true) { - onSetSetting("focus_icon", it) - } SettingsDropdownRow(textOf("焦点通知", "Focus Notification"), focusOptions, extras.focus, true, largeText = true) { onSetSetting("focus", it) if (it == "off" && extras.preserveSmallIcon != "off") { @@ -1204,8 +1200,6 @@ private fun BatchApplyDialog( noChange to "不更改", "generic_progress" to "下载", "notification_island" to "通知超级岛", - "notification_island_lite" to "通知超级岛 | 精简", - "download_lite" to "下载|Lite", "ai_notification_island" to "AI 通知超级岛", ) val rendererOptions = listOf( @@ -1213,6 +1207,7 @@ private fun BatchApplyDialog( "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", "image_text_with_buttons_4_wrap" to "封面组件 + 自动换行", "image_text_with_right_text_button" to "新图文组件 + 右侧文本按钮", + "image_text_with_progress" to "IM 图文组件 + 进度条", ) val dynamicHighlightOptions = listOf( noChange to "不更改", @@ -1227,7 +1222,6 @@ private fun BatchApplyDialog( var renderer by remember { mutableStateOf(noChange) } var timeout by remember { mutableStateOf("") } var icon by remember { mutableStateOf(noChange) } - var focusIcon by remember { mutableStateOf(noChange) } var focus by remember { mutableStateOf(noChange) } var preserveSmallIcon by remember { mutableStateOf(noChange) } var showIslandIcon by remember { mutableStateOf(noChange) } @@ -1281,7 +1275,6 @@ private fun BatchApplyDialog( putIfChanged("template", template) putIfChanged("renderer", renderer) putIfChanged("icon", icon) - putIfChanged("focus_icon", focusIcon) putIfChanged("focus", focus) putIfChanged("preserve_small_icon", preserveSmallIconValue) putIfChanged("show_island_icon", showIslandIcon) @@ -1402,7 +1395,6 @@ private fun BatchApplyDialog( ChannelSectionTitle("焦点通知") BatchSheetSectionCard { - SettingsDropdownRow("焦点图标", iconModeOptions, focusIcon, true, largeText = true) { focusIcon = it } SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { focus = it if (it == "off") { @@ -1642,7 +1634,6 @@ private fun ChannelSettingsScreenPreview() { channelExtras = mapOf( channelId to ChannelExtraSettings( icon = "app_icon", - focusIcon = "notif_small", focus = "on", preserveSmallIcon = "off", showIslandIcon = "on", From 44313eb00bd9d3dc7e0a33f89a94d9318222315e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8B=E6=98=9F?= <14321555+xcb157342@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 18:59:04 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/prefs/SettingsRepository.kt | 4 +- .../hyperisland/data/prefs/SettingsState.kt | 2 +- .../hyperisland/ui/ComposeMainActivity.kt | 88 +++++---- .../hyperisland/ui/ai/AiConfigScreen.kt | 56 ++++-- .../ui/app/AppChannelsViewModel.kt | 32 +-- .../github/hyperisland/ui/app/AppsScreens.kt | 186 +++++++++--------- .../ui/settings/SettingsViewModel.kt | 2 +- 7 files changed, 207 insertions(+), 163 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt index de59c93b..2341b0f4 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsRepository.kt @@ -29,7 +29,7 @@ class SettingsRepository(private val context: Context) { marqueeFeature = prefs.getBoolean(PrefKeys.MARQUEE_FEATURE, false), marqueeSpeed = prefs.getInt(PrefKeys.MARQUEE_SPEED, 100).coerceIn(20, 500), bigIslandMaxWidthEnabled = prefs.getBoolean(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, false), - bigIslandMaxWidth = prefs.getInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, 600).coerceIn(500, 1000), + bigIslandMaxWidth = prefs.getInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, 200).coerceIn(50, 500), useFloatingNavigationBar = prefs.getBoolean(PrefKeys.USE_FLOATING_NAVIGATION_BAR, false), unlockAllFocus = prefs.getBoolean(PrefKeys.UNLOCK_ALL_FOCUS, false), unlockFocusAuth = prefs.getBoolean(PrefKeys.UNLOCK_FOCUS_AUTH, false), @@ -59,7 +59,7 @@ class SettingsRepository(private val context: Context) { } fun setBigIslandMaxWidth(value: Int) { - prefs.edit().putInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, value.coerceIn(500, 1000)).apply() + prefs.edit().putInt(PrefKeys.BIG_ISLAND_MAX_WIDTH, value.coerceIn(50, 500)).apply() } fun setDesktopIconHidden(hidden: Boolean) { diff --git a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt index 6371986f..acfd9a44 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/data/prefs/SettingsState.kt @@ -13,7 +13,7 @@ data class SettingsState( val marqueeFeature: Boolean = false, val marqueeSpeed: Int = 100, val bigIslandMaxWidthEnabled: Boolean = false, - val bigIslandMaxWidth: Int = 600, + val bigIslandMaxWidth: Int = 200, val useFloatingNavigationBar: Boolean = false, val unlockAllFocus: Boolean = false, val unlockFocusAuth: Boolean = false, diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt index 8a7458da..dae8effd 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ComposeMainActivity.kt @@ -410,7 +410,7 @@ private const val GITHUB_REPO_URL = "https://github.com/1812z/HyperIsland" private const val GITHUB_RELEASE_URL = "https://github.com/1812z/HyperIsland/releases/latest" private const val QQ_GROUP_NUMBER = "1045114341" private const val DEFAULT_MARQUEE_SPEED = 100 -private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 600 +private const val DEFAULT_BIG_ISLAND_MAX_WIDTH = 200 private const val BAR_BLUR_RADIUS = 28f private const val BAR_BLUR_NOISE = 0.016f @@ -1368,7 +1368,7 @@ private fun HyperIslandComposeApp() { val vm: AppChannelsViewModel = viewModel() val state by vm.uiState.collectAsStateWithLifecycle() LaunchedEffect(scene.packageName) { - vm.setPackageNameIfEmpty(scene.packageName) + vm.setPackageName(scene.packageName) } AppChannelsScreen( state = state, @@ -1395,7 +1395,7 @@ private fun HyperIslandComposeApp() { val vm: AppChannelsViewModel = viewModel() val state by vm.uiState.collectAsStateWithLifecycle() LaunchedEffect(scene.packageName) { - vm.setPackageNameIfEmpty(scene.packageName) + vm.setPackageName(scene.packageName) } ChannelSettingsScreen( state = state, @@ -1462,7 +1462,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { handleNavigationBack() }) { Icon( imageVector = MiuixIcons.Basic.ArrowRight, - contentDescription = "返回", + contentDescription = textOf(uiLanguage, "返回", "Back"), modifier = Modifier.rotate(180f), tint = MaterialTheme.colorScheme.onSurface, ) @@ -1472,7 +1472,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { appsExitSelectionRequestId += 1 }) { FaIcon( glyph = FaGlyph.Times, - contentDescription = "退出多选", + contentDescription = textOf(uiLanguage, "退出多选", "Exit multi-select"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1494,7 +1494,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { showAppChannelsMenu = true }) { Icon( imageVector = MiuixIcons.Regular.MoreCircle, - contentDescription = "渠道页更多操作", + contentDescription = textOf(uiLanguage, "渠道页更多操作", "More channel actions"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1505,11 +1505,11 @@ private fun HyperIslandComposeApp() { onDismissFinished = {}, ) { val menuItems = listOf( - "启用全部渠道" to { + textOf(uiLanguage, "启用全部渠道", "Enable all channels") to { showAppChannelsMenu = false appChannelsEnableAllRequestId += 1 }, - "批量设置渠道配置" to { + textOf(uiLanguage, "批量设置渠道配置", "Batch edit channel settings") to { showAppChannelsMenu = false appChannelsBatchRequestId += 1 }, @@ -1542,7 +1542,7 @@ private fun HyperIslandComposeApp() { ) { Icon( imageVector = MiuixIcons.Regular.Search, - contentDescription = "搜索", + contentDescription = textOf(uiLanguage, "搜索", "Search"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1553,7 +1553,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { showBlacklistMenu = true }) { Icon( imageVector = MiuixIcons.Regular.MoreCircle, - contentDescription = "黑名单页更多操作", + contentDescription = textOf(uiLanguage, "黑名单页更多操作", "More blacklist actions"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1564,23 +1564,27 @@ private fun HyperIslandComposeApp() { onDismissFinished = {}, ) { val menuItems = listOf( - "游戏预设" to { + textOf(uiLanguage, "游戏预设", "Game preset") to { showBlacklistMenu = false blacklistVm.applyGamePreset() }, - "全部加入" to { + textOf(uiLanguage, "全部加入", "Add all visible") to { showBlacklistMenu = false blacklistVm.enableAllVisible() }, - "全部移除" to { + textOf(uiLanguage, "全部移除", "Remove all visible") to { showBlacklistMenu = false blacklistVm.disableAllVisible() }, - (if (blacklistState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + (if (blacklistState.showSystemApps) { + textOf(uiLanguage, "隐藏系统应用", "Hide system apps") + } else { + textOf(uiLanguage, "显示系统应用", "Show system apps") + }) to { showBlacklistMenu = false blacklistVm.setShowSystemApps(!blacklistState.showSystemApps) }, - "刷新" to { + textOf(uiLanguage, "刷新", "Refresh") to { showBlacklistMenu = false blacklistVm.refresh() }, @@ -1605,21 +1609,21 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { openExternalUrl(context, DOCUMENTATION_URL) }) { Icon( imageVector = MiuixIcons.Regular.Info, - contentDescription = "文档", + contentDescription = textOf(uiLanguage, "文档", "Documentation"), tint = MaterialTheme.colorScheme.onSurface, ) } MiuixIconButton(onClick = { showSponsorDialog = true }) { Icon( imageVector = MiuixIcons.Regular.Create, - contentDescription = "赞助", + contentDescription = textOf(uiLanguage, "赞助", "Sponsor"), tint = MaterialTheme.colorScheme.onSurface, ) } MiuixIconButton(onClick = { showRestartDialog = true }) { Icon( imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "重启作用域", + contentDescription = textOf(uiLanguage, "重启作用域", "Restart scopes"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1638,7 +1642,7 @@ private fun HyperIslandComposeApp() { ) { Icon( imageVector = MiuixIcons.Regular.Search, - contentDescription = "搜索", + contentDescription = textOf(uiLanguage, "搜索", "Search"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1646,7 +1650,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { appsSelectionRequestId += 1 }) { Icon( imageVector = MiuixIcons.Regular.SelectAll, - contentDescription = "进入多选", + contentDescription = textOf(uiLanguage, "进入多选", "Enter multi-select"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1658,7 +1662,7 @@ private fun HyperIslandComposeApp() { MiuixIconButton(onClick = { showAppsMenu = true }) { Icon( imageVector = MiuixIcons.Regular.MoreCircle, - contentDescription = "更多操作", + contentDescription = textOf(uiLanguage, "更多操作", "More actions"), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -1670,42 +1674,50 @@ private fun HyperIslandComposeApp() { ) { val menuItems = if (appsSelectionMode) { listOf( - (if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + (if (appsState.showSystemApps) { + textOf(uiLanguage, "隐藏系统应用", "Hide system apps") + } else { + textOf(uiLanguage, "显示系统应用", "Show system apps") + }) to { showAppsMenu = false appsVm.setShowSystemApps(!appsState.showSystemApps) }, - "开启已选" to { + textOf(uiLanguage, "开启已选", "Enable selected") to { showAppsMenu = false appsEnableSelectedRequestId += 1 }, - "关闭已选" to { + textOf(uiLanguage, "关闭已选", "Disable selected") to { showAppsMenu = false appsDisableSelectedRequestId += 1 }, - "选中已启用" to { + textOf(uiLanguage, "选中已启用", "Select enabled") to { showAppsMenu = false appsSelectEnabledRequestId += 1 }, - "批量设置渠道配置" to { + textOf(uiLanguage, "批量设置渠道配置", "Batch edit channel settings") to { showAppsMenu = false appsBatchSelectedRequestId += 1 }, ) } else { listOf( - (if (appsState.showSystemApps) "隐藏系统应用" else "显示系统应用") to { + (if (appsState.showSystemApps) { + textOf(uiLanguage, "隐藏系统应用", "Hide system apps") + } else { + textOf(uiLanguage, "显示系统应用", "Show system apps") + }) to { showAppsMenu = false appsVm.setShowSystemApps(!appsState.showSystemApps) }, - "一键开启全部" to { + textOf(uiLanguage, "一键开启全部", "Enable all") to { showAppsMenu = false appsEnableAllRequestId += 1 }, - "一键关闭全部" to { + textOf(uiLanguage, "一键关闭全部", "Disable all") to { showAppsMenu = false appsDisableAllRequestId += 1 }, - "刷新" to { + textOf(uiLanguage, "刷新", "Refresh") to { showAppsMenu = false appsVm.refresh() }, @@ -1750,7 +1762,7 @@ private fun HyperIslandComposeApp() { appsSearchFieldValue = it appsVm.setQuery(it.text) }, - label = "搜索应用 / 包名", + label = textOf(uiLanguage, "搜索应用 / 包名", "Search app / package"), useLabelAsPlaceholder = true, modifier = Modifier .focusRequester(appsSearchFocusRequester) @@ -1789,7 +1801,11 @@ private fun HyperIslandComposeApp() { MaterialTheme.colorScheme.onSurfaceVariant } Text( - "已选择 ${appsState.selectedPackages.size} 项", + textOf( + uiLanguage, + "已选择 ${appsState.selectedPackages.size} 项", + "Selected ${appsState.selectedPackages.size}", + ), style = MaterialTheme.typography.bodySmall, color = selectedTextColor, ) @@ -1827,7 +1843,7 @@ private fun HyperIslandComposeApp() { blacklistSearchFieldValue = it blacklistVm.setQuery(it.text) }, - label = "搜索应用 / 包名", + label = textOf(uiLanguage, "搜索应用 / 包名", "Search app / package"), useLabelAsPlaceholder = true, modifier = Modifier .focusRequester(blacklistSearchFocusRequester) @@ -2535,7 +2551,7 @@ private fun SettingsScreen( valueText = "${state.bigIslandMaxWidth} dp", value = state.bigIslandMaxWidth.toFloat(), defaultValue = DEFAULT_BIG_ISLAND_MAX_WIDTH.toFloat(), - valueRange = 500f..1000f, + valueRange = 50f..500f, steps = 54, onCheckedChange = { onToggle(PrefKeys.BIG_ISLAND_MAX_WIDTH_ENABLED, it) }, onValueChange = { onBigIslandWidth(it.toInt()) }, @@ -2665,7 +2681,7 @@ private fun MainActivityPreview() { marqueeFeature = true, marqueeSpeed = 120, bigIslandMaxWidthEnabled = true, - bigIslandMaxWidth = 680, + bigIslandMaxWidth = 320, useFloatingNavigationBar = true, defaultFirstFloat = false, defaultEnableFloat = true, @@ -3099,7 +3115,7 @@ private fun SliderResetButton( ) { Icon( imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "恢复默认值", + contentDescription = textOf("恢复默认值", "Reset to default"), modifier = Modifier.size(13.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt index e958035e..040b65b2 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/ai/AiConfigScreen.kt @@ -47,6 +47,7 @@ import top.yukonga.miuix.kmp.icon.extended.Show import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.scrollEndHaptic import io.github.hyperisland.ui.isAppInDarkTheme +import io.github.hyperisland.ui.textOf private const val DEFAULT_AI_TIMEOUT = 3 private const val DEFAULT_AI_TEMPERATURE = 0.1 @@ -104,7 +105,7 @@ fun AiConfigScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - "AI 增强", + textOf("AI 增强", "AI Enhancement"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, @@ -116,10 +117,13 @@ fun AiConfigScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("启用 AI 摘要", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("启用 AI 摘要", "Enable AI Summary"), color = MaterialTheme.colorScheme.onBackground) Spacer(modifier = Modifier.height(4.dp)) Text( - "由 AI 生成超级岛左右文本,超时或失败时自动回退", + textOf( + "由 AI 生成超级岛左右文本,超时或失败时自动回退", + "Use AI to generate left/right Dynamic Island text, with automatic fallback on timeout or failure", + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -134,7 +138,7 @@ fun AiConfigScreen( if (state.enabled) { Text( - "API 参数", + textOf("API 参数", "API Parameters"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, @@ -144,7 +148,7 @@ fun AiConfigScreen( MiuixTextField( value = state.url, onValueChange = { onUpdate(state.copy(url = it)) }, - label = "API 地址(必须完整)", + label = textOf("API 地址(必须完整)", "API URL (full endpoint required)"), useLabelAsPlaceholder = true, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -152,7 +156,7 @@ fun AiConfigScreen( MiuixTextField( value = state.apiKey, onValueChange = { onUpdate(state.copy(apiKey = it)) }, - label = "API 密钥", + label = textOf("API 密钥", "API Key"), useLabelAsPlaceholder = true, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -161,7 +165,7 @@ fun AiConfigScreen( MiuixIconButton(onClick = { keyObscured = !keyObscured }) { Icon( imageVector = if (keyObscured) MiuixIcons.Regular.Show else MiuixIcons.Regular.Hide, - contentDescription = if (keyObscured) "显示密钥" else "隐藏密钥", + contentDescription = if (keyObscured) textOf("显示密钥", "Show key") else textOf("隐藏密钥", "Hide key"), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -170,7 +174,7 @@ fun AiConfigScreen( MiuixTextField( value = state.model, onValueChange = { onUpdate(state.copy(model = it)) }, - label = "模型", + label = textOf("模型", "Model"), useLabelAsPlaceholder = true, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -178,7 +182,7 @@ fun AiConfigScreen( MiuixTextField( value = state.prompt, onValueChange = { onUpdate(state.copy(prompt = it)) }, - label = "系统提示词", + label = textOf("系统提示词", "System Prompt"), useLabelAsPlaceholder = true, modifier = Modifier.fillMaxWidth(), minLines = 2, @@ -191,10 +195,13 @@ fun AiConfigScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text("提示词放在用户消息", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("提示词放在用户消息", "Put prompt in user message"), color = MaterialTheme.colorScheme.onBackground) Spacer(modifier = Modifier.height(4.dp)) Text( - "某些模型不支持系统指令,开启后将提示词放在用户消息中", + textOf( + "某些模型不支持系统指令,开启后将提示词放在用户消息中", + "Some models do not support system instructions; when enabled, the prompt is moved into the user message", + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -206,7 +213,7 @@ fun AiConfigScreen( } SliderItem( - title = "AI 响应超时", + title = textOf("AI 响应超时", "AI Response Timeout"), subtitle = "", valueText = "${state.timeout}s", value = state.timeout.toFloat(), @@ -217,8 +224,8 @@ fun AiConfigScreen( onResetToDefault = { onUpdate(state.copy(timeout = DEFAULT_AI_TIMEOUT)) }, ) SliderItem( - title = "采样温度 (Temperature)", - subtitle = "控制回答的随机性。0 为准确,1 则更具创意", + title = textOf("采样温度 (Temperature)", "Sampling Temperature"), + subtitle = textOf("控制回答的随机性。0 为准确,1 则更具创意", "Controls randomness. 0 is more deterministic; 1 is more creative"), valueText = String.format("%.1f", state.temperature), value = state.temperature.toFloat(), defaultValue = DEFAULT_AI_TEMPERATURE.toFloat(), @@ -228,8 +235,8 @@ fun AiConfigScreen( onResetToDefault = { onUpdate(state.copy(temperature = DEFAULT_AI_TEMPERATURE)) }, ) SliderItem( - title = "最大 Token 数 (Max Tokens)", - subtitle = "限制 AI 生成回答的最大长度", + title = textOf("最大 Token 数 (Max Tokens)", "Max Tokens"), + subtitle = textOf("限制 AI 生成回答的最大长度", "Limits the maximum response length"), valueText = state.maxTokens.toString(), value = state.maxTokens.toFloat(), defaultValue = DEFAULT_AI_MAX_TOKENS.toFloat(), @@ -242,12 +249,12 @@ fun AiConfigScreen( Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { MiuixButton(onClick = onTest, enabled = !state.testing, modifier = Modifier.weight(1f)) { Text( - if (state.testing) "测试中..." else "测试连接", + if (state.testing) textOf("测试中...", "Testing...") else textOf("测试连接", "Test Connection"), color = MaterialTheme.colorScheme.onBackground, ) } MiuixButton(onClick = onSave, modifier = Modifier.weight(1f)) { - Text("保存", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("保存", "Save"), color = MaterialTheme.colorScheme.onBackground) } } @@ -261,7 +268,10 @@ fun AiConfigScreen( MiuixCard(modifier = aiCardModifier(Modifier.fillMaxWidth())) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", + textOf( + "AI 会接收每条通知的应用包名、标题、正文,并返回短左文案(来源)与短右文案(内容)。兼容 OpenAI 格式 API(如 DeepSeek、Claude)。无响应时会自动回退默认逻辑。", + "AI receives each notification's package name, title, and body, then returns short left (source) and right (content) copy. Compatible with OpenAI-style APIs (e.g., DeepSeek, Claude). Falls back automatically when no response is returned.", + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -343,7 +353,7 @@ private fun SliderResetButton( ) { Icon( imageVector = MiuixIcons.Regular.Refresh, - contentDescription = "恢复默认值", + contentDescription = textOf("恢复默认值", "Reset to default"), modifier = Modifier.size(13.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -371,7 +381,11 @@ private fun TestResultCard(text: String) { .padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(if (isSuccess) "测试结果(成功)" else "测试结果(失败)", color = fg, fontWeight = FontWeight.SemiBold) + Text( + if (isSuccess) textOf("测试结果(成功)", "Test Result (Success)") else textOf("测试结果(失败)", "Test Result (Failed)"), + color = fg, + fontWeight = FontWeight.SemiBold, + ) Text(text, color = fg, style = MaterialTheme.typography.bodySmall) } } diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt index 88fb2ae9..4dc31856 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppChannelsViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,6 +17,7 @@ class AppChannelsViewModel( ) : AndroidViewModel(app) { private val repo = AppAdaptationRepository(app) private var packageName: String = savedStateHandle["packageName"] ?: "" + private var refreshJob: Job? = null private val _uiState = MutableStateFlow(AppChannelsUiState(packageName = packageName)) val uiState: StateFlow = _uiState.asStateFlow() @@ -24,10 +26,11 @@ class AppChannelsViewModel( refresh() } - fun setPackageNameIfEmpty(value: String) { - if (packageName.isNotBlank() || value.isBlank()) return + fun setPackageName(value: String) { + if (value.isBlank() || value == packageName) return packageName = value - _uiState.update { it.copy(packageName = value, error = null) } + savedStateHandle["packageName"] = value + _uiState.value = AppChannelsUiState(packageName = value, loading = true) refresh() } @@ -36,36 +39,41 @@ class AppChannelsViewModel( _uiState.update { it.copy(loading = false, error = "包名为空") } return } - viewModelScope.launch { + val currentPackage = packageName + refreshJob?.cancel() + refreshJob = viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } - val channelsResult = runCatching { repo.loadChannels(packageName) } + val channelsResult = runCatching { repo.loadChannels(currentPackage) } val channels = channelsResult.getOrNull() if (channels == null) { _uiState.update { it.copy( loading = false, + packageName = currentPackage, error = "无法读取通知渠道,请确认 Root 权限", ) } return@launch } - val enabled = repo.getEnabledChannels(packageName) - val appItem = repo.loadAppItem(packageName) - val appEnabled = repo.isAppEnabled(packageName) + val enabled = repo.getEnabledChannels(currentPackage) + val appItem = repo.loadAppItem(currentPackage) + val appEnabled = repo.isAppEnabled(currentPackage) val templateMap = channels.associate { ch -> - ch.id to repo.getChannelTemplate(packageName, ch.id) + ch.id to repo.getChannelTemplate(currentPackage, ch.id) } val timeoutMap = channels.associate { ch -> - ch.id to repo.getChannelTimeout(packageName, ch.id) + ch.id to repo.getChannelTimeout(currentPackage, ch.id) } val extrasMap = channels.associate { ch -> - ch.id to repo.getChannelExtras(packageName, ch.id) + ch.id to repo.getChannelExtras(currentPackage, ch.id) } + if (currentPackage != packageName) return@launch _uiState.update { it.copy( + packageName = currentPackage, loading = false, - appName = appItem?.appName ?: packageName, + appName = appItem?.appName ?: currentPackage, appIcon = appItem?.icon ?: byteArrayOf(), appEnabled = appEnabled, channels = channels, diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt index b812187f..1744cd35 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/app/AppsScreens.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import io.github.hyperisland.data.prefs.PrefKeys import io.github.hyperisland.ui.FaGlyph import io.github.hyperisland.ui.FaIcon +import io.github.hyperisland.ui.LocalUiLanguage import io.github.hyperisland.ui.isAppInDarkTheme import io.github.hyperisland.ui.textOf import top.yukonga.miuix.kmp.basic.Button as MiuixButton @@ -861,6 +862,15 @@ private fun ChannelSettingsContent( enableColorPalette = true, onConfirm = { onSetSetting("out_effect_color", it.trim()) }, ) + InputDialogRow( + title = textOf("超级岛高级自定义", "Advanced Island Customization"), + subtitle = textOf("点击后输入 JSON,留空可清空", "Tap to enter JSON, or leave blank to clear"), + value = extras.islandCustom, + emptyValueText = textOf("未设置", "Not Set"), + dialogTitle = textOf("修改超级岛高级自定义", "Edit Advanced Island Customization"), + dialogDescription = textOf("请输入 JSON 字符串,留空可清空", "Enter a JSON string, or leave blank to clear"), + onConfirm = { onSetSetting("island_custom", it.trim()) }, + ) SwitchSettingRow( title = textOf("左侧高亮", "Left Highlight"), checked = extras.showLeftHighlight == "on", @@ -919,23 +929,14 @@ private fun ChannelSettingsContent( onSetSetting("outer_glow", it) } InputDialogRow( - title = textOf("焦点表达式自定义", "Custom Focus Expression"), + title = textOf("焦点高级自定义", "Advanced Focus Customization"), subtitle = textOf("点击后输入 JSON,留空可清空", "Tap to enter JSON, or leave blank to clear"), value = extras.focusCustom, emptyValueText = textOf("未设置", "Not Set"), - dialogTitle = textOf("修改焦点表达式自定义", "Edit Custom Focus Expression"), + dialogTitle = textOf("修改焦点高级自定义", "Edit Advanced Focus Customization"), dialogDescription = textOf("请输入 JSON 字符串,留空可清空", "Enter a JSON string, or leave blank to clear"), onConfirm = { onSetSetting("focus_custom", it.trim()) }, ) - InputDialogRow( - title = textOf("岛表达式自定义", "Custom Island Expression"), - subtitle = textOf("点击后输入 JSON,留空可清空", "Tap to enter JSON, or leave blank to clear"), - value = extras.islandCustom, - emptyValueText = textOf("未设置", "Not Set"), - dialogTitle = textOf("修改岛表达式自定义", "Edit Custom Island Expression"), - dialogDescription = textOf("请输入 JSON 字符串,留空可清空", "Enter a JSON string, or leave blank to clear"), - onConfirm = { onSetSetting("island_custom", it.trim()) }, - ) } } } @@ -1146,7 +1147,7 @@ private fun InputDialogRow( onClick = { showDialog = false }, modifier = Modifier.weight(1f), ) { - Text("取消", color = MaterialTheme.colorScheme.onBackground) + Text(textOf("取消", "Cancel"), color = MaterialTheme.colorScheme.onBackground) } MiuixButton( onClick = { @@ -1157,7 +1158,7 @@ private fun InputDialogRow( colors = MiuixButtonDefaults.buttonColorsPrimary(), ) { Text( - text = "确认", + text = textOf("确认", "Confirm"), color = MiuixTheme.colorScheme.onPrimary, ) } @@ -1173,49 +1174,50 @@ private fun BatchApplyDialog( onApply: (Map) -> Unit, ) { val noChange = "__NO_CHANGE__" + val uiLanguage = LocalUiLanguage.current val context = LocalContext.current val defaultDynamicHighlightEnabled = remember(context) { context.getSharedPreferences(PrefKeys.PREFS_NAME, Context.MODE_PRIVATE) .getBoolean(PrefKeys.DEFAULT_DYNAMIC_HIGHLIGHT_COLOR, false) } val triStateOptions = listOf( - noChange to "不更改", - "default" to "默认", - "on" to "开启", - "off" to "关闭", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "default" to textOf(uiLanguage, "默认", "Default"), + "on" to textOf(uiLanguage, "开启", "On"), + "off" to textOf(uiLanguage, "关闭", "Off"), ) val toggleOptions = listOf( - noChange to "不更改", - "on" to "开启", - "off" to "关闭", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "on" to textOf(uiLanguage, "开启", "On"), + "off" to textOf(uiLanguage, "关闭", "Off"), ) val iconModeOptions = listOf( - noChange to "不更改", - "auto" to "自动", - "notif_small" to "通知小图标", - "notif_large" to "通知大图标", - "app_icon" to "应用图标", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "auto" to textOf(uiLanguage, "自动", "Auto"), + "notif_small" to textOf(uiLanguage, "通知小图标", "Small Notification Icon"), + "notif_large" to textOf(uiLanguage, "通知大图标", "Large Notification Icon"), + "app_icon" to textOf(uiLanguage, "应用图标", "App Icon"), ) val templateOptions = listOf( - noChange to "不更改", - "generic_progress" to "下载", - "notification_island" to "通知超级岛", - "ai_notification_island" to "AI 通知超级岛", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "generic_progress" to textOf(uiLanguage, "下载", "Download"), + "notification_island" to textOf(uiLanguage, "通知超级岛", "Notification Island"), + "ai_notification_island" to textOf(uiLanguage, "AI 通知超级岛", "AI Notification Island"), ) val rendererOptions = listOf( - noChange to "不更改", - "image_text_with_buttons_4" to "新图文组件 + 底部文本按钮", - "image_text_with_buttons_4_wrap" to "封面组件 + 自动换行", - "image_text_with_right_text_button" to "新图文组件 + 右侧文本按钮", - "image_text_with_progress" to "IM 图文组件 + 进度条", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "image_text_with_buttons_4" to textOf(uiLanguage, "新图文组件 + 底部文本按钮", "Image+Text+Bottom Text Buttons"), + "image_text_with_buttons_4_wrap" to textOf(uiLanguage, "封面组件 + 自动换行", "Cover Info+Auto Wrap"), + "image_text_with_right_text_button" to textOf(uiLanguage, "新图文组件 + 右侧文本按钮", "Image+Text+Right Text Button"), + "image_text_with_progress" to textOf(uiLanguage, "IM 图文组件 + 进度条", "IM Chat Info + Progress"), ) val dynamicHighlightOptions = listOf( - noChange to "不更改", - "default" to "默认", - "on" to "开启", - "off" to "关闭", - "dark" to "暗", - "darker" to "更暗", + noChange to textOf(uiLanguage, "不更改", "No Change"), + "default" to textOf(uiLanguage, "默认", "Default"), + "on" to textOf(uiLanguage, "开启", "On"), + "off" to textOf(uiLanguage, "关闭", "Off"), + "dark" to textOf(uiLanguage, "暗", "Dark"), + "darker" to textOf(uiLanguage, "更暗", "Darker"), ) var template by remember { mutableStateOf(noChange) } @@ -1259,7 +1261,7 @@ private fun BatchApplyDialog( MiuixIconButton(onClick = onDismiss) { FaIcon( glyph = FaGlyph.Times, - contentDescription = "取消", + contentDescription = textOf(uiLanguage, "取消", "Cancel"), tint = MaterialTheme.colorScheme.onBackground, ) } @@ -1311,7 +1313,7 @@ private fun BatchApplyDialog( ) { FaIcon( glyph = FaGlyph.Check, - contentDescription = "应用", + contentDescription = textOf(uiLanguage, "应用", "Apply"), tint = MaterialTheme.colorScheme.onBackground, ) } @@ -1325,109 +1327,113 @@ private fun BatchApplyDialog( .overScrollVertical() .scrollEndHaptic(), ) { - ChannelSectionTitle("模板") + ChannelSectionTitle(textOf(uiLanguage, "模板", "Template")) BatchSheetSectionCard { - SettingsDropdownRow("模板", templateOptions, template, true, largeText = true) { template = it } - SettingsDropdownRow("样式", rendererOptions, renderer, true, largeText = true) { renderer = it } + SettingsDropdownRow(textOf(uiLanguage, "模板", "Template"), templateOptions, template, true, largeText = true) { template = it } + SettingsDropdownRow(textOf(uiLanguage, "样式", "Style"), rendererOptions, renderer, true, largeText = true) { renderer = it } } - ChannelSectionTitle("岛") + ChannelSectionTitle(textOf(uiLanguage, "岛", "Island")) BatchSheetSectionCard { - SettingsDropdownRow("超级岛图标", iconModeOptions, icon, true, largeText = true) { icon = it } - SettingsDropdownRow("大岛图标", triStateOptions, showIslandIcon, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "超级岛图标", "Dynamic Island Icon"), iconModeOptions, icon, true, largeText = true) { icon = it } + SettingsDropdownRow(textOf(uiLanguage, "大岛图标", "Large Island Icon"), triStateOptions, showIslandIcon, true, largeText = true) { showIslandIcon = it } - SettingsDropdownRow("初次展开", triStateOptions, firstFloat, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "初次展开", "Expand on First Arrival"), triStateOptions, firstFloat, true, largeText = true) { firstFloat = it } - SettingsDropdownRow("更新展开", triStateOptions, enableFloat, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "更新展开", "Expand on Update"), triStateOptions, enableFloat, true, largeText = true) { enableFloat = it } - SettingsDropdownRow("消息滚动速度", triStateOptions, marquee, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "消息滚动速度", "Marquee Speed"), triStateOptions, marquee, true, largeText = true) { marquee = it } InputDialogRow( - title = "自动消失时长", - subtitle = "点击后在对话框中输入,留空表示不更改", + title = textOf(uiLanguage, "自动消失时长", "Auto Dismiss Timeout"), + subtitle = textOf(uiLanguage, "点击后在对话框中输入,留空表示不更改", "Tap to enter a value in the dialog. Leave blank for no change"), value = timeout, - emptyValueText = "不更改", - dialogTitle = "修改自动消失时长", - dialogDescription = "值应该大于等于 1 并小于等于 30,留空表示不更改", + emptyValueText = textOf(uiLanguage, "不更改", "No Change"), + dialogTitle = textOf(uiLanguage, "修改自动消失时长", "Edit Auto Dismiss Timeout"), + dialogDescription = textOf(uiLanguage, "值应该大于等于 1 并小于等于 30,留空表示不更改", "Value must be between 1 and 30. Leave blank for no change"), onConfirm = { timeout = it.trim() }, ) InputDialogRow( - title = "高亮颜色", - subtitle = if (dynamicHighlightEnabled) "动态取色开启时不可编辑" else "点击后输入 #RRGGBB,留空表示不更改", + title = textOf(uiLanguage, "高亮颜色", "Highlight Color"), + subtitle = if (dynamicHighlightEnabled) { + textOf(uiLanguage, "动态取色开启时不可编辑", "Disabled while dynamic highlight is enabled") + } else { + textOf(uiLanguage, "点击后输入 #RRGGBB,留空表示不更改", "Tap to enter #RRGGBB. Leave blank for no change") + }, value = highlightColor, - emptyValueText = "不更改", - dialogTitle = "修改高亮颜色", - dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", + emptyValueText = textOf(uiLanguage, "不更改", "No Change"), + dialogTitle = textOf(uiLanguage, "修改高亮颜色", "Edit Highlight Color"), + dialogDescription = textOf(uiLanguage, "请输入 #RRGGBB 格式,留空表示不更改", "Enter a color in #RRGGBB format. Leave blank for no change"), enableColorPalette = true, enabled = !dynamicHighlightEnabled, onConfirm = { highlightColor = it.trim() }, ) InputDialogRow( - title = "外圈光效颜色", - subtitle = "点击后输入 #RRGGBB,留空表示不更改", + title = textOf(uiLanguage, "外圈光效颜色", "Outer Glow Color"), + subtitle = textOf(uiLanguage, "点击后输入 #RRGGBB,留空表示不更改", "Tap to enter #RRGGBB. Leave blank for no change"), value = outEffectColor, - emptyValueText = "不更改", - dialogTitle = "修改外圈光效颜色", - dialogDescription = "请输入 #RRGGBB 格式,留空表示不更改", + emptyValueText = textOf(uiLanguage, "不更改", "No Change"), + dialogTitle = textOf(uiLanguage, "修改外圈光效颜色", "Edit Outer Glow Color"), + dialogDescription = textOf(uiLanguage, "请输入 #RRGGBB 格式,留空表示不更改", "Enter a color in #RRGGBB format. Leave blank for no change"), enableColorPalette = true, onConfirm = { outEffectColor = it.trim() }, ) - SettingsDropdownRow("高亮动态取色", dynamicHighlightOptions, dynamicHighlightColor, true, largeText = true) { + InputDialogRow( + title = textOf(uiLanguage, "超级岛高级自定义", "Advanced Island Customization"), + subtitle = textOf(uiLanguage, "点击后输入 JSON,留空表示不更改", "Tap to enter JSON. Leave blank for no change"), + value = islandCustom, + emptyValueText = textOf(uiLanguage, "不更改", "No Change"), + dialogTitle = textOf(uiLanguage, "修改超级岛高级自定义", "Edit Advanced Island Customization"), + dialogDescription = textOf(uiLanguage, "请输入 JSON 字符串,留空表示不更改", "Enter a JSON string. Leave blank for no change"), + onConfirm = { islandCustom = it.trim() }, + ) + SettingsDropdownRow(textOf(uiLanguage, "高亮动态取色", "Dynamic Highlight Color"), dynamicHighlightOptions, dynamicHighlightColor, true, largeText = true) { dynamicHighlightColor = it } - SettingsDropdownRow("左侧高亮", toggleOptions, showLeftHighlight, hasHighlightColor, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "左侧高亮", "Left Highlight"), toggleOptions, showLeftHighlight, hasHighlightColor, largeText = true) { showLeftHighlight = it } - SettingsDropdownRow("右侧高亮", toggleOptions, showRightHighlight, hasHighlightColor, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "右侧高亮", "Right Highlight"), toggleOptions, showRightHighlight, hasHighlightColor, largeText = true) { showRightHighlight = it } - SettingsDropdownRow("左侧窄字体", toggleOptions, showLeftNarrowFont, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "左侧窄字体", "Left Narrow Font"), toggleOptions, showLeftNarrowFont, true, largeText = true) { showLeftNarrowFont = it } - SettingsDropdownRow("右侧窄字体", toggleOptions, showRightNarrowFont, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "右侧窄字体", "Right Narrow Font"), toggleOptions, showRightNarrowFont, true, largeText = true) { showRightNarrowFont = it } } - ChannelSectionTitle("焦点通知") + ChannelSectionTitle(textOf(uiLanguage, "焦点通知", "Focus Notification")) BatchSheetSectionCard { - SettingsDropdownRow("焦点通知", triStateOptions, focus, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "焦点通知", "Focus Notification"), triStateOptions, focus, true, largeText = true) { focus = it if (it == "off") { preserveSmallIcon = "off" } } - SettingsDropdownRow("状态栏图标", triStateOptions, preserveSmallIconValue, !focusDisabled, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "状态栏图标", "Status Bar Icon"), triStateOptions, preserveSmallIconValue, !focusDisabled, largeText = true) { preserveSmallIcon = it } - SettingsDropdownRow("锁屏通知恢复", triStateOptions, restoreLockscreen, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "锁屏通知恢复", "Restore on Lock Screen"), triStateOptions, restoreLockscreen, true, largeText = true) { restoreLockscreen = it } - SettingsDropdownRow("外圈光效", triStateOptions, outerGlow, true, largeText = true) { + SettingsDropdownRow(textOf(uiLanguage, "外圈光效", "Outer Glow"), triStateOptions, outerGlow, true, largeText = true) { outerGlow = it } InputDialogRow( - title = "焦点表达式自定义", - subtitle = "点击后输入 JSON,留空表示不更改", + title = textOf(uiLanguage, "焦点高级自定义", "Advanced Focus Customization"), + subtitle = textOf(uiLanguage, "点击后输入 JSON,留空表示不更改", "Tap to enter JSON. Leave blank for no change"), value = focusCustom, - emptyValueText = "不更改", - dialogTitle = "修改焦点表达式自定义", - dialogDescription = "请输入 JSON 字符串,留空表示不更改", + emptyValueText = textOf(uiLanguage, "不更改", "No Change"), + dialogTitle = textOf(uiLanguage, "修改焦点高级自定义", "Edit Advanced Focus Customization"), + dialogDescription = textOf(uiLanguage, "请输入 JSON 字符串,留空表示不更改", "Enter a JSON string. Leave blank for no change"), onConfirm = { focusCustom = it.trim() }, ) - InputDialogRow( - title = "岛表达式自定义", - subtitle = "点击后输入 JSON,留空表示不更改", - value = islandCustom, - emptyValueText = "不更改", - dialogTitle = "修改岛表达式自定义", - dialogDescription = "请输入 JSON 字符串,留空表示不更改", - onConfirm = { islandCustom = it.trim() }, - ) } } }, diff --git a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt index 9ae04fb4..3bde3b89 100644 --- a/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/io/github/hyperisland/ui/settings/SettingsViewModel.kt @@ -81,7 +81,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { fun updateBigIslandMaxWidth(value: Int) { repo.setBigIslandMaxWidth(value) - _uiState.update { it.copy(bigIslandMaxWidth = value.coerceIn(500, 1000)) } + _uiState.update { it.copy(bigIslandMaxWidth = value.coerceIn(50, 500)) } } fun setDesktopIconHidden(hidden: Boolean) {