From 3aa141b41f33f00574a94b1decb4228f667d07e4 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:49:26 +0800 Subject: [PATCH 1/3] feat(ui): align the launcher UI with LeviLauncher for Windows Rebuild the app shell and every launcher page after the Windows launcher, keeping touch-sized controls. Shell - Left icon rail with the active-item pill, a top bar with back/forward history, breadcrumbs, news and the account avatar, and the page area with the concave corner. A top navigation bar is available as on Windows. - Keep the navigation and pages clear of the front camera cutout. Look - Windows palette (emerald, zinc neutrals) for light and dark mode, header cards, white content cards, segmented tabs, pill buttons, icon chips. - Home, instances, instance settings, content management and lists, mods, external mods, CurseForge, LeviMC mods, settings, about, accounts, news and quick launch follow the Windows layouts and wording. Personalization (same options as Windows) - Theme mode: light, dark, manual schedule, sunrise/sunset, follow system; quick theme toggle in the navigation. - Theme color per light/dark mode with the Windows presets and more colors, plus a custom color; base layer under the wallpaper. - Layout and motion: top navigation bar, disable animations. - Wallpaper: folder with random/sequential order and next image, single image, fit mode, blur, brightness and opacity. - Interface material: presets, interface opacity, wallpaper overlay and readability protection. --- .../launcher/core/content/ContentManager.java | 14 +- .../core/curseforge/CurseForgeClient.java | 83 + .../minecraft/MinecraftLoadingActivity.kt | 2 + .../minecraft/MinecraftProcessRestarter.kt | 2 + .../launcher/ui/activities/AboutActivity.java | 53 +- .../ui/activities/AccountsActivity.java | 30 +- .../launcher/ui/activities/BaseActivity.java | 440 +++- .../ui/activities/ContentDetailsActivity.java | 10 +- .../ui/activities/ContentListActivity.java | 113 +- .../activities/ContentManagementActivity.java | 176 +- .../ui/activities/CurseForgeActivity.java | 196 +- .../ui/activities/ExternalModsActivity.java | 105 +- .../activities/InstanceSettingsActivity.java | 148 +- .../ui/activities/InstancesActivity.java | 235 +- .../launcher/ui/activities/MainActivity.java | 147 +- .../ui/activities/ModsFullscreenActivity.java | 228 +- .../launcher/ui/activities/NavRoutes.java | 77 + .../launcher/ui/activities/NewsActivity.java | 6 +- .../launcher/ui/activities/NewsAdapter.java | 19 +- .../ui/activities/SettingsActivity.java | 1593 +++++++++---- .../launcher/ui/adapter/AccountsAdapter.java | 30 +- .../ui/adapter/CurseForgeContentAdapter.java | 68 +- .../ui/adapter/ExternalModsAdapter.java | 122 +- .../launcher/ui/adapter/ModsAdapter.java | 48 +- .../ui/adapter/QuickLaunchAdapter.java | 56 +- .../launcher/ui/adapter/ServersAdapter.java | 7 + .../launcher/ui/adapter/WorldsAdapter.java | 2 +- .../launcher/ui/animation/DynamicAnim.java | 27 +- .../launcher/ui/packages/BrowseFragment.java | 212 +- .../ui/packages/InstalledFragment.java | 57 +- .../launcher/ui/packages/PackageActivity.java | 20 +- .../ui/packages/PackagesActivity.java | 137 +- .../launcher/ui/packages/SettingsCard.java | 15 +- .../launcher/ui/widget/CatalogSelect.java | 173 ++ .../launcher/ui/widget/SegmentedControl.java | 163 ++ .../launcher/ui/widget/ShellContentFrame.java | 44 + .../org/levimc/launcher/ui/widget/UiKit.java | 124 + .../util/LauncherBackgroundController.java | 3 + .../launcher/util/PersonalizationManager.java | 360 ++- .../levimc/launcher/util/ThemeManager.java | 143 +- .../levimc/launcher/util/ThemePalette.java | 116 + .../org/levimc/launcher/util/UiSettings.java | 199 ++ .../levimc/launcher/util/WallpaperFolder.java | 148 ++ app/src/main/res/drawable/bg_shell_avatar.xml | 8 + .../main/res/drawable/bg_shell_content.xml | 12 + .../res/drawable/bg_shell_icon_button.xml | 9 + .../res/drawable/bg_shell_rail_indicator.xml | 5 + app/src/main/res/drawable/bg_ui_card.xml | 7 + .../main/res/drawable/bg_ui_card_inner.xml | 7 + app/src/main/res/drawable/bg_ui_icon_chip.xml | 6 + app/src/main/res/drawable/bg_ui_input.xml | 18 + app/src/main/res/drawable/bg_ui_row.xml | 10 + .../res/drawable/bg_ui_segment_selected.xml | 5 + .../main/res/drawable/bg_ui_segment_track.xml | 5 + app/src/main/res/drawable/bg_ui_strip.xml | 7 + app/src/main/res/drawable/bg_ui_tag.xml | 6 + .../main/res/drawable/bg_ui_tag_accent.xml | 6 + .../res/drawable/content_bg_icon_button.xml | 10 + .../res/drawable/content_bg_item_card.xml | 18 + .../main/res/drawable/content_bg_thumb.xml | 5 + .../res/drawable/home_bg_launch_group.xml | 6 + app/src/main/res/drawable/ic_ui_add.xml | 11 + .../main/res/drawable/ic_ui_arrow_back.xml | 11 + .../main/res/drawable/ic_ui_arrow_forward.xml | 11 + app/src/main/res/drawable/ic_ui_bolt.xml | 11 + app/src/main/res/drawable/ic_ui_camera.xml | 11 + .../main/res/drawable/ic_ui_chevron_right.xml | 11 + .../res/drawable/ic_ui_cloud_download.xml | 11 + app/src/main/res/drawable/ic_ui_code.xml | 11 + app/src/main/res/drawable/ic_ui_delete.xml | 11 + app/src/main/res/drawable/ic_ui_download.xml | 11 + .../main/res/drawable/ic_ui_expand_more.xml | 11 + app/src/main/res/drawable/ic_ui_filter.xml | 11 + app/src/main/res/drawable/ic_ui_folder.xml | 11 + app/src/main/res/drawable/ic_ui_globe.xml | 11 + app/src/main/res/drawable/ic_ui_group.xml | 11 + app/src/main/res/drawable/ic_ui_heart.xml | 11 + app/src/main/res/drawable/ic_ui_image.xml | 11 + app/src/main/res/drawable/ic_ui_lightbulb.xml | 11 + app/src/main/res/drawable/ic_ui_monitor.xml | 11 + app/src/main/res/drawable/ic_ui_more.xml | 11 + .../main/res/drawable/ic_ui_open_in_new.xml | 11 + app/src/main/res/drawable/ic_ui_palette.xml | 11 + app/src/main/res/drawable/ic_ui_person.xml | 11 + app/src/main/res/drawable/ic_ui_puzzle.xml | 11 + app/src/main/res/drawable/ic_ui_refresh.xml | 11 + app/src/main/res/drawable/ic_ui_schedule.xml | 11 + app/src/main/res/drawable/ic_ui_search.xml | 11 + app/src/main/res/drawable/ic_ui_select.xml | 11 + app/src/main/res/drawable/ic_ui_server.xml | 11 + app/src/main/res/drawable/ic_ui_sort.xml | 11 + app/src/main/res/drawable/ic_ui_star.xml | 11 + app/src/main/res/drawable/ic_ui_storage.xml | 11 + app/src/main/res/drawable/ic_ui_sunrise.xml | 11 + app/src/main/res/drawable/ic_ui_tune.xml | 11 + app/src/main/res/drawable/ic_ui_upload.xml | 11 + .../res/drawable/instances_bg_card_icon.xml | 6 + .../res/drawable/instances_bg_icon_slot.xml | 11 + .../main/res/drawable/instances_bg_search.xml | 17 + .../res/drawable/instances_ic_check_box.xml | 11 + app/src/main/res/drawable/misc_bg_avatar.xml | 5 + .../main/res/drawable/misc_bg_tag_danger.xml | 6 + .../main/res/drawable/settings_ic_layers.xml | 11 + app/src/main/res/layout/activity_about.xml | 750 +++--- app/src/main/res/layout/activity_accounts.xml | 416 ++-- .../res/layout/activity_content_details.xml | 249 +- .../main/res/layout/activity_content_list.xml | 428 ++-- .../layout/activity_content_management.xml | 719 ++++-- .../main/res/layout/activity_curseforge.xml | 266 ++- .../res/layout/activity_external_mods.xml | 304 ++- .../res/layout/activity_instance_settings.xml | 806 ++++--- .../main/res/layout/activity_instances.xml | 246 +- app/src/main/res/layout/activity_main.xml | 1044 +++++---- .../res/layout/activity_mods_fullscreen.xml | 340 +-- app/src/main/res/layout/activity_news.xml | 131 +- app/src/main/res/layout/activity_package.xml | 123 +- app/src/main/res/layout/activity_packages.xml | 125 +- .../main/res/layout/activity_quick_launch.xml | 89 +- app/src/main/res/layout/activity_settings.xml | 2054 ++++++++++------- .../res/layout/catalog_pkg_header_lead.xml | 40 + .../main/res/layout/fragment_pkg_browse.xml | 482 ++-- .../res/layout/fragment_pkg_installed.xml | 536 ++--- .../res/layout/item_curseforge_content.xml | 196 +- .../res/layout/item_curseforge_footer.xml | 53 +- app/src/main/res/layout/item_external_mod.xml | 206 +- .../main/res/layout/item_instance_card.xml | 74 +- app/src/main/res/layout/item_mod.xml | 243 +- app/src/main/res/layout/item_msft_account.xml | 95 +- app/src/main/res/layout/item_news.xml | 158 +- .../main/res/layout/item_pkg_installed.xml | 49 +- app/src/main/res/layout/item_pkg_package.xml | 214 +- .../main/res/layout/item_pkg_page_footer.xml | 62 +- app/src/main/res/layout/item_quick_launch.xml | 87 +- .../main/res/layout/item_resource_pack.xml | 75 +- app/src/main/res/layout/item_screenshot.xml | 84 +- app/src/main/res/layout/item_server.xml | 96 +- app/src/main/res/layout/item_world.xml | 118 +- app/src/main/res/layout/nav_bar.xml | 220 -- .../main/res/layout/shell_account_cluster.xml | 75 + app/src/main/res/layout/shell_navbar.xml | 129 ++ app/src/main/res/layout/shell_sidebar.xml | 175 ++ .../res/layout/view_pkg_settings_card.xml | 477 ++-- app/src/main/res/values-night/colors.xml | 55 +- app/src/main/res/values-night/colors_pkg.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- .../res/values-zh-rCN/strings_ui_catalog.xml | 22 + .../res/values-zh-rCN/strings_ui_content.xml | 18 + .../res/values-zh-rCN/strings_ui_home.xml | 9 + .../values-zh-rCN/strings_ui_instances.xml | 8 + .../res/values-zh-rCN/strings_ui_misc.xml | 25 + .../res/values-zh-rCN/strings_ui_mods.xml | 23 + .../res/values-zh-rCN/strings_ui_settings.xml | 86 + .../res/values-zh-rCN/strings_ui_shell.xml | 25 + app/src/main/res/values/colors.xml | 59 +- .../main/res/values/strings_ui_catalog.xml | 23 + .../main/res/values/strings_ui_content.xml | 18 + app/src/main/res/values/strings_ui_home.xml | 9 + .../main/res/values/strings_ui_instances.xml | 8 + app/src/main/res/values/strings_ui_misc.xml | 26 + app/src/main/res/values/strings_ui_mods.xml | 23 + .../main/res/values/strings_ui_settings.xml | 86 + app/src/main/res/values/strings_ui_shell.xml | 25 + app/src/main/res/values/styles_pkg.xml | 34 +- app/src/main/res/values/styles_settings.xml | 78 + app/src/main/res/values/styles_ui.xml | 220 ++ 165 files changed, 12315 insertions(+), 7160 deletions(-) create mode 100644 app/src/main/java/org/levimc/launcher/ui/activities/NavRoutes.java create mode 100644 app/src/main/java/org/levimc/launcher/ui/widget/CatalogSelect.java create mode 100644 app/src/main/java/org/levimc/launcher/ui/widget/SegmentedControl.java create mode 100644 app/src/main/java/org/levimc/launcher/ui/widget/ShellContentFrame.java create mode 100644 app/src/main/java/org/levimc/launcher/ui/widget/UiKit.java create mode 100644 app/src/main/java/org/levimc/launcher/util/ThemePalette.java create mode 100644 app/src/main/java/org/levimc/launcher/util/UiSettings.java create mode 100644 app/src/main/java/org/levimc/launcher/util/WallpaperFolder.java create mode 100644 app/src/main/res/drawable/bg_shell_avatar.xml create mode 100644 app/src/main/res/drawable/bg_shell_content.xml create mode 100644 app/src/main/res/drawable/bg_shell_icon_button.xml create mode 100644 app/src/main/res/drawable/bg_shell_rail_indicator.xml create mode 100644 app/src/main/res/drawable/bg_ui_card.xml create mode 100644 app/src/main/res/drawable/bg_ui_card_inner.xml create mode 100644 app/src/main/res/drawable/bg_ui_icon_chip.xml create mode 100644 app/src/main/res/drawable/bg_ui_input.xml create mode 100644 app/src/main/res/drawable/bg_ui_row.xml create mode 100644 app/src/main/res/drawable/bg_ui_segment_selected.xml create mode 100644 app/src/main/res/drawable/bg_ui_segment_track.xml create mode 100644 app/src/main/res/drawable/bg_ui_strip.xml create mode 100644 app/src/main/res/drawable/bg_ui_tag.xml create mode 100644 app/src/main/res/drawable/bg_ui_tag_accent.xml create mode 100644 app/src/main/res/drawable/content_bg_icon_button.xml create mode 100644 app/src/main/res/drawable/content_bg_item_card.xml create mode 100644 app/src/main/res/drawable/content_bg_thumb.xml create mode 100644 app/src/main/res/drawable/home_bg_launch_group.xml create mode 100644 app/src/main/res/drawable/ic_ui_add.xml create mode 100644 app/src/main/res/drawable/ic_ui_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_ui_arrow_forward.xml create mode 100644 app/src/main/res/drawable/ic_ui_bolt.xml create mode 100644 app/src/main/res/drawable/ic_ui_camera.xml create mode 100644 app/src/main/res/drawable/ic_ui_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_ui_cloud_download.xml create mode 100644 app/src/main/res/drawable/ic_ui_code.xml create mode 100644 app/src/main/res/drawable/ic_ui_delete.xml create mode 100644 app/src/main/res/drawable/ic_ui_download.xml create mode 100644 app/src/main/res/drawable/ic_ui_expand_more.xml create mode 100644 app/src/main/res/drawable/ic_ui_filter.xml create mode 100644 app/src/main/res/drawable/ic_ui_folder.xml create mode 100644 app/src/main/res/drawable/ic_ui_globe.xml create mode 100644 app/src/main/res/drawable/ic_ui_group.xml create mode 100644 app/src/main/res/drawable/ic_ui_heart.xml create mode 100644 app/src/main/res/drawable/ic_ui_image.xml create mode 100644 app/src/main/res/drawable/ic_ui_lightbulb.xml create mode 100644 app/src/main/res/drawable/ic_ui_monitor.xml create mode 100644 app/src/main/res/drawable/ic_ui_more.xml create mode 100644 app/src/main/res/drawable/ic_ui_open_in_new.xml create mode 100644 app/src/main/res/drawable/ic_ui_palette.xml create mode 100644 app/src/main/res/drawable/ic_ui_person.xml create mode 100644 app/src/main/res/drawable/ic_ui_puzzle.xml create mode 100644 app/src/main/res/drawable/ic_ui_refresh.xml create mode 100644 app/src/main/res/drawable/ic_ui_schedule.xml create mode 100644 app/src/main/res/drawable/ic_ui_search.xml create mode 100644 app/src/main/res/drawable/ic_ui_select.xml create mode 100644 app/src/main/res/drawable/ic_ui_server.xml create mode 100644 app/src/main/res/drawable/ic_ui_sort.xml create mode 100644 app/src/main/res/drawable/ic_ui_star.xml create mode 100644 app/src/main/res/drawable/ic_ui_storage.xml create mode 100644 app/src/main/res/drawable/ic_ui_sunrise.xml create mode 100644 app/src/main/res/drawable/ic_ui_tune.xml create mode 100644 app/src/main/res/drawable/ic_ui_upload.xml create mode 100644 app/src/main/res/drawable/instances_bg_card_icon.xml create mode 100644 app/src/main/res/drawable/instances_bg_icon_slot.xml create mode 100644 app/src/main/res/drawable/instances_bg_search.xml create mode 100644 app/src/main/res/drawable/instances_ic_check_box.xml create mode 100644 app/src/main/res/drawable/misc_bg_avatar.xml create mode 100644 app/src/main/res/drawable/misc_bg_tag_danger.xml create mode 100644 app/src/main/res/drawable/settings_ic_layers.xml create mode 100644 app/src/main/res/layout/catalog_pkg_header_lead.xml delete mode 100644 app/src/main/res/layout/nav_bar.xml create mode 100644 app/src/main/res/layout/shell_account_cluster.xml create mode 100644 app/src/main/res/layout/shell_navbar.xml create mode 100644 app/src/main/res/layout/shell_sidebar.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_catalog.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_content.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_home.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_instances.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_misc.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_mods.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_settings.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_ui_shell.xml create mode 100644 app/src/main/res/values/strings_ui_catalog.xml create mode 100644 app/src/main/res/values/strings_ui_content.xml create mode 100644 app/src/main/res/values/strings_ui_home.xml create mode 100644 app/src/main/res/values/strings_ui_instances.xml create mode 100644 app/src/main/res/values/strings_ui_misc.xml create mode 100644 app/src/main/res/values/strings_ui_mods.xml create mode 100644 app/src/main/res/values/strings_ui_settings.xml create mode 100644 app/src/main/res/values/strings_ui_shell.xml create mode 100644 app/src/main/res/values/styles_settings.xml create mode 100644 app/src/main/res/values/styles_ui.xml diff --git a/app/src/main/java/org/levimc/launcher/core/content/ContentManager.java b/app/src/main/java/org/levimc/launcher/core/content/ContentManager.java index ff25bbbf9..3b5545bda 100644 --- a/app/src/main/java/org/levimc/launcher/core/content/ContentManager.java +++ b/app/src/main/java/org/levimc/launcher/core/content/ContentManager.java @@ -35,10 +35,12 @@ public static final class ContentCounts { public final int worlds; public final int resourcePacks; public final int skinPacks; - ContentCounts(int worlds, int resourcePacks, int skinPacks) { + public final int behaviorPacks; + ContentCounts(int worlds, int resourcePacks, int skinPacks, int behaviorPacks) { this.worlds = worlds; this.resourcePacks = resourcePacks; this.skinPacks = skinPacks; + this.behaviorPacks = behaviorPacks; } } @@ -106,7 +108,8 @@ public synchronized void configureStorageDirectories(File worldsDir, File resour serversLiveData.setValue(java.util.Collections.emptyList()); String key = countKey(selected); countsLiveData.setValue(new ContentCounts(countPreferences.getInt(key + "w", 0), - countPreferences.getInt(key + "r", 0), countPreferences.getInt(key + "s", 0))); + countPreferences.getInt(key + "r", 0), countPreferences.getInt(key + "s", 0), + countPreferences.getInt(key + "b", 0))); }; if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) clear.run(); else mainHandler.post(clear); @@ -163,10 +166,12 @@ public void refreshCounts() { File[] root = locations; ContentCounts counts = new ContentCounts(worldManager.countWorlds(root[0]), resourcePackManager.countPacks(root[1], ResourcePackItem.PackType.RESOURCE_PACK), - resourcePackManager.countPacks(root[3], ResourcePackItem.PackType.SKIN_PACK)); + resourcePackManager.countPacks(root[3], ResourcePackItem.PackType.SKIN_PACK), + resourcePackManager.countPacks(root[2], ResourcePackItem.PackType.BEHAVIOR_PACK)); String key = countKey(root); countPreferences.edit().putInt(key + "w", counts.worlds) - .putInt(key + "r", counts.resourcePacks).putInt(key + "s", counts.skinPacks).apply(); + .putInt(key + "r", counts.resourcePacks).putInt(key + "s", counts.skinPacks) + .putInt(key + "b", counts.behaviorPacks).apply(); return counts; }, countsLiveData::setValue); } @@ -183,6 +188,7 @@ public void refreshResourcePacks() { public void refreshBehaviorPacks() { requestRefresh(2, refreshExecutor, resourcePackManager::getBehaviorPacks, behaviorPacksLiveData::setValue); + refreshCounts(); } public void refreshSkinPacks() { diff --git a/app/src/main/java/org/levimc/launcher/core/curseforge/CurseForgeClient.java b/app/src/main/java/org/levimc/launcher/core/curseforge/CurseForgeClient.java index 4e3a8dfa3..cafea8b93 100644 --- a/app/src/main/java/org/levimc/launcher/core/curseforge/CurseForgeClient.java +++ b/app/src/main/java/org/levimc/launcher/core/curseforge/CurseForgeClient.java @@ -2,12 +2,20 @@ import org.levimc.launcher.BuildConfig; import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.levimc.launcher.core.curseforge.models.ContentSearchResponse; import org.levimc.launcher.core.curseforge.models.ModFilesResponse; import org.levimc.launcher.core.curseforge.models.StringResponse; import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import okhttp3.Call; @@ -169,4 +177,79 @@ public void onResponse(Call call, Response response) { } }); } + + /** The game's versions (newest first), for the search's game version filter. */ + public void getGameVersions(CurseForgeCallback> callback) { + Request request = new Request.Builder() + .url(BASE_URL + "/v2/games/" + GAME_ID_MINECRAFT + "/versions") + .addHeader("x-api-key", API_KEY) + .addHeader("Accept", "application/json") + .build(); + + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + callback.onError(e); + } + + @Override + public void onResponse(Call call, Response response) { + if (!response.isSuccessful()) { + callback.onError(new IOException("Unexpected code " + response)); + return; + } + try { + String json = response.body().string(); + callback.onSuccess(parseGameVersions(json)); + } catch (Exception e) { + callback.onError(e); + } + } + }); + } + + // v2 shape: {"data":[{"type":N,"versions":[{"id":..,"slug":..,"name":"1.21.0"}, ...]}, ...]} + static List parseGameVersions(String json) { + Set names = new LinkedHashSet<>(); + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + JsonArray data = root.has("data") && root.get("data").isJsonArray() ? root.getAsJsonArray("data") : new JsonArray(); + for (JsonElement group : data) { + if (!group.isJsonObject() || !group.getAsJsonObject().has("versions")) continue; + JsonElement versions = group.getAsJsonObject().get("versions"); + if (!versions.isJsonArray()) continue; + for (JsonElement v : versions.getAsJsonArray()) { + String name = null; + if (v.isJsonPrimitive()) { + name = v.getAsString(); + } else if (v.isJsonObject() && v.getAsJsonObject().has("name")) { + name = v.getAsJsonObject().get("name").getAsString(); + } + if (name != null && !name.trim().isEmpty() && Character.isDigit(name.trim().charAt(0))) { + names.add(name.trim()); + } + } + } + List out = new ArrayList<>(names); + out.sort((a, b) -> compareVersions(b, a)); + return out; + } + + private static int compareVersions(String a, String b) { + String[] pa = a.split("[^0-9]+"); + String[] pb = b.split("[^0-9]+"); + for (int i = 0; i < Math.max(pa.length, pb.length); i++) { + long x = i < pa.length && !pa[i].isEmpty() ? parseLong(pa[i]) : 0; + long y = i < pb.length && !pb[i].isEmpty() ? parseLong(pb[i]) : 0; + if (x != y) return Long.compare(x, y); + } + return a.compareTo(b); + } + + private static long parseLong(String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return 0; + } + } } diff --git a/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftLoadingActivity.kt b/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftLoadingActivity.kt index e7f73a815..36f53e8fc 100644 --- a/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftLoadingActivity.kt +++ b/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftLoadingActivity.kt @@ -90,6 +90,8 @@ class MinecraftLoadingActivity : BaseActivity(), MinecraftRuntimePreparer.Progre override fun shouldSkipNavBar(): Boolean = true + override fun avoidsCutoutWithoutShell(): Boolean = false + override fun onResume() { super.onResume() hideSystemUi() diff --git a/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftProcessRestarter.kt b/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftProcessRestarter.kt index 9ce37ea3b..8f2717cd5 100644 --- a/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftProcessRestarter.kt +++ b/app/src/main/java/org/levimc/launcher/core/minecraft/MinecraftProcessRestarter.kt @@ -106,6 +106,8 @@ class LauncherRestartActivity : BaseActivity() { override fun shouldSkipNavBar(): Boolean = true + override fun avoidsCutoutWithoutShell(): Boolean = false + override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) registerCloseReceiver() diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/AboutActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/AboutActivity.java index 000aad301..d58b28ff2 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/AboutActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/AboutActivity.java @@ -1,14 +1,20 @@ package org.levimc.launcher.ui.activities; import android.content.Intent; +import android.content.res.ColorStateList; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; +import android.view.View; +import android.widget.ImageView; import android.widget.TextView; +import com.google.android.material.button.MaterialButton; + import org.levimc.launcher.R; import org.levimc.launcher.ui.animation.DynamicAnim; +import org.levimc.launcher.ui.widget.UiKit; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -27,6 +33,8 @@ public class AboutActivity extends BaseActivity { private static final String URL_ISSUES = "https://github.com/LiteLDev/LeviLauncher-Android/issues"; private static final String MAINTAINER_AVATAR_URL = "https://yt3.googleusercontent.com/ft4khqXZ_fQn-DbSLg91kQy3_JUQ_73rbg18nOcmMtunX5bq25jzrThWQAk9YsFkTKFesUL7sg8=s160-c-k-c0x00ffffff-no-rj"; private static final String URL_YOUTUBE = "https://www.youtube.com/c/mrpokeg"; + private static final String URL_LICENSE = "https://github.com/LiteLDev/LeviLauncher-Android/blob/HEAD/LICENSE"; + private static final String URL_RHYMC = "https://www.rhymc.com/"; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final OkHttpClient client = new OkHttpClient(); @@ -47,28 +55,37 @@ protected void onCreate(Bundle savedInstanceState) { private void styleBadges() { TextView authorBadge = findViewById(R.id.author_badge); TextView maintainerBadge = findViewById(R.id.maintainer_badge); + if (authorBadge != null) UiKit.tintSoftAccent(authorBadge, 7); + if (maintainerBadge != null) UiKit.tintSoftAccent(maintainerBadge, 7); + + tintChip(R.id.about_authors_chip, R.id.about_authors_icon, UiKit.accent(this)); + tintChip(R.id.about_thanks_chip, R.id.about_thanks_icon, UiKit.AMBER); + tintChip(R.id.about_rhymc_chip, R.id.about_rhymc_icon, UiKit.BLUE); + tintChip(R.id.about_sponsors_chip, R.id.about_sponsors_icon, UiKit.ROSE); + tintChip(R.id.about_source_chip, R.id.about_source_icon, UiKit.BLUE); + + // Brand icons keep their own colors (the YouTube logo has a white play mark). + clearIconTint(R.id.btn_afdian); + clearIconTint(R.id.btn_patreon); + clearIconTint(R.id.btn_youtube_maintainer); + + MaterialButton starFork = findViewById(R.id.btn_star_fork); + if (starFork != null) UiKit.applyPrimary(starFork); + } + + private void tintChip(int chipId, int iconId, int color) { + View chip = findViewById(chipId); + ImageView icon = findViewById(iconId); + if (chip != null) UiKit.tintIconChip(chip, icon, color); + } - org.levimc.launcher.util.PersonalizationManager pm = new org.levimc.launcher.util.PersonalizationManager(this); - int accent = pm.getAccentColor(); - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(4 * getResources().getDisplayMetrics().density); - if (authorBadge != null) { - authorBadge.setBackground(gd); - authorBadge.setTextColor(android.graphics.Color.WHITE); - } - if (maintainerBadge != null) { - maintainerBadge.setBackground(gd); - maintainerBadge.setTextColor(android.graphics.Color.WHITE); - } - } + private void clearIconTint(int buttonId) { + MaterialButton button = findViewById(buttonId); + if (button != null) button.setIconTint((ColorStateList) null); } private void setupNavBar() { setActiveNavTab(R.id.nav_tab_about); - findViewById(R.id.nav_tab_about).setOnClickListener(v -> {}); } private void loadAvatars() { @@ -110,6 +127,8 @@ private void setupLinks() { setupLinkButton(R.id.btn_issues, URL_ISSUES); setupLinkButton(R.id.btn_star_fork, URL_REPO); setupLinkButton(R.id.btn_youtube_maintainer, URL_YOUTUBE); + setupLinkButton(R.id.btn_license, URL_LICENSE); + setupLinkButton(R.id.btn_rhymc_website, URL_RHYMC); } private void setupLinkButton(int viewId, String url) { diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/AccountsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/AccountsActivity.java index 44aa2af48..890d2035f 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/AccountsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/AccountsActivity.java @@ -2,7 +2,6 @@ import android.content.Intent; import android.content.res.ColorStateList; -import android.graphics.Color; import android.os.Bundle; import android.view.View; import android.widget.ImageButton; @@ -16,12 +15,15 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.recyclerview.widget.LinearLayoutManager; +import com.google.android.material.button.MaterialButton; + import org.levimc.launcher.R; import org.levimc.launcher.core.auth.MsftAccountStore; import org.levimc.launcher.core.auth.MsftAuthManager; import org.levimc.launcher.ui.adapter.AccountsAdapter; import org.levimc.launcher.ui.animation.DynamicAnim; import org.levimc.launcher.ui.dialogs.LoadingDialog; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.AccountTextUtils; import org.levimc.launcher.util.PersonalizationManager; @@ -55,6 +57,7 @@ public class AccountsActivity extends BaseActivity { private ActivityResultLauncher loginLauncher; private ExecutorService executor = Executors.newSingleThreadExecutor(); private LoadingDialog loadingDialog; + private boolean shownEnabled; @Override protected void onCreate(Bundle savedInstanceState) { @@ -89,6 +92,12 @@ protected void onCreate(Bundle savedInstanceState) { if (bottomAddButton != null) bottomAddButton.setOnClickListener(addAction); if (bottomAddButton != null) DynamicAnim.applyPressScale(bottomAddButton); + View goSettings = findViewById(R.id.accounts_go_settings); + if (goSettings != null) { + goSettings.setOnClickListener(v -> startActivity(new Intent(this, SettingsActivity.class))); + DynamicAnim.applyPressScale(goSettings); + } + adapter.setOnAccountActionListener(new AccountsAdapter.OnAccountActionListener() { @Override public void onSetActive(MsftAccountStore.MsftAccount account) { @@ -147,10 +156,10 @@ private void applyAccountAccent() { if (avatarProgress != null) { avatarProgress.setIndeterminateTintList(ColorStateList.valueOf(accent)); } - if (bottomAddButton != null) { - bottomAddButton.setBackgroundTintList(ColorStateList.valueOf(accent)); - bottomAddButton.setTextColor(Color.WHITE); + if (bottomAddButton instanceof MaterialButton) { + UiKit.applyPrimary((MaterialButton) bottomAddButton); } + if (privilegesText != null) UiKit.tintSoftAccent(privilegesText, 7); } private MsftAccountStore.MsftAccount getActiveAccount() { @@ -163,11 +172,13 @@ private MsftAccountStore.MsftAccount getActiveAccount() { private void refreshUI() { boolean enabled = org.levimc.launcher.settings.FeatureSettings.getInstance().isLauncherManagedMcLoginEnabled(); + shownEnabled = enabled; View mainContent = findViewById(R.id.main_content_container); View disabledState = findViewById(R.id.disabled_state_container); if (mainContent != null) mainContent.setVisibility(enabled ? View.VISIBLE : View.GONE); if (disabledState != null) disabledState.setVisibility(enabled ? View.GONE : View.VISIBLE); + if (bottomAddButton != null) bottomAddButton.setVisibility(enabled ? View.VISIBLE : View.GONE); if (!enabled) return; @@ -175,6 +186,7 @@ private void refreshUI() { if (active == null) { gamertagText.setText(getString(R.string.not_signed_in)); privilegesText.setText(""); + privilegesText.setVisibility(View.GONE); xuidText.setText(""); if (xboxAvatar != null) { xboxAvatar.setImageDrawable(null); @@ -186,6 +198,7 @@ private void refreshUI() { String displayName = AccountTextUtils.displayNameOrNotSigned(this, active); gamertagText.setText(displayName); privilegesText.setText(getString(R.string.accounts_active_privilege)); + privilegesText.setVisibility(View.VISIBLE); xuidText.setText(active.xuid != null ? active.xuid : ""); loadXboxAvatar(active); if (rightCardContainer != null) rightCardContainer.setVisibility(View.VISIBLE); @@ -200,6 +213,15 @@ private void refreshUI() { refreshNavAccountUI(); } + @Override + protected void onResume() { + super.onResume(); + // The feature switch may have been changed in Settings ("Go to Settings"). + if (isFinishing() || gamertagText == null) return; + boolean enabled = org.levimc.launcher.settings.FeatureSettings.getInstance().isLauncherManagedMcLoginEnabled(); + if (enabled != shownEnabled) refreshUI(); + } + @Override protected void onNavAccountChanged() { refreshUI(); diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/BaseActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/BaseActivity.java index 6199219b7..c16a9cf5c 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/BaseActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/BaseActivity.java @@ -41,6 +41,23 @@ import org.levimc.launcher.util.PersonalizationManager; import org.levimc.launcher.util.LauncherBackgroundController; import org.levimc.launcher.util.ThemeManager; +import org.levimc.launcher.util.ThemePalette; +import org.levimc.launcher.util.UiSettings; +import org.levimc.launcher.util.WallpaperFolder; +import org.levimc.launcher.ui.widget.UiKit; + +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Looper; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.activity.OnBackPressedCallback; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -54,7 +71,27 @@ public class BaseActivity extends AppCompatActivity { private int appliedThemeGeneration = -1; private int appliedPersonalizationGeneration = -1; private int appliedBackgroundEffectGeneration = -1; + private int appliedLayoutGeneration = -1; private boolean navBarInjected = false; + private int activeNavTabId = View.NO_ID; + private boolean finishingByBack; + private final Handler scheduleHandler = new Handler(Looper.getMainLooper()); + private final Runnable scheduleCheck = new Runnable() { + @Override + public void run() { + if (new ThemeManager(BaseActivity.this).refreshScheduledMode()) { + recreate(); + return; + } + scheduleHandler.postDelayed(this, 60_000L); + } + }; + + /** Pages left with Back, for the Forward button (Windows browser-style history). */ + private static final ArrayDeque sForwardHistory = new ArrayDeque<>(); + /** Page classes alive in the task, so breadcrumbs only jump to pages that can be restored. */ + private static final Set> sLivePages = new HashSet<>(); + private static final String EXTRA_FROM_FORWARD = "org.levimc.launcher.extra.FROM_FORWARD"; private final OkHttpClient navAvatarClient = new OkHttpClient(); private final ExecutorService navAccountExecutor = Executors.newSingleThreadExecutor(); private ActivityResultLauncher navAccountLoginLauncher; @@ -86,7 +123,25 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { appliedThemeGeneration = ThemeManager.getThemeChangeGeneration(); appliedPersonalizationGeneration = PersonalizationManager.getChangeGeneration(); appliedBackgroundEffectGeneration = PersonalizationManager.getBackgroundEffectGeneration(); + appliedLayoutGeneration = UiSettings.getLayoutGeneration(); + UiSettings.refreshAnimations(this); super.onCreate(savedInstanceState); + sLivePages.add(getClass()); + if (savedInstanceState == null && !getIntent().getBooleanExtra(EXTRA_FROM_FORWARD, false)) { + sForwardHistory.clear(); + } + // Lowest-priority back handler: pages' own callbacks run first. Marks a back navigation so + // finish() can offer the page to the Forward button. + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + finishingByBack = true; + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + setEnabled(true); + if (!isFinishing()) finishingByBack = false; + } + }); navAccountLoginLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> handleNavAccountLoginResult(result.getResultCode(), result.getData())); @@ -114,38 +169,70 @@ public void setContentView(View view, ViewGroup.LayoutParams params) { private void wrapWithNavBar(View contentView) { if (shouldSkipNavBar()) { super.setContentView(contentView); + if (avoidsCutoutWithoutShell()) applyCutoutInsets(contentView); applyPersonalization(); return; } - LinearLayout wrapper = new LinearLayout(this); - wrapper.setOrientation(LinearLayout.VERTICAL); - wrapper.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - - View navBar = LayoutInflater.from(this).inflate(R.layout.nav_bar, wrapper, false); - wrapper.addView(navBar); - - LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f); - contentView.setLayoutParams(contentParams); - wrapper.addView(contentView); + boolean navbar = UiSettings.useTopNavbar(this); + View shell = LayoutInflater.from(this).inflate( + navbar ? R.layout.shell_navbar : R.layout.shell_sidebar, null, false); + FrameLayout holder = shell.findViewById(R.id.shell_content); + holder.addView(contentView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - contentView.setAlpha(0f); - contentView.setTranslationY(8f * getResources().getDisplayMetrics().density); + if (!UiSettings.animationsDisabled()) { + contentView.setAlpha(0f); + contentView.setTranslationY(8f * getResources().getDisplayMetrics().density); + } - super.setContentView(wrapper); + super.setContentView(shell); + applyCutoutInsets(shell); navBarInjected = true; setupBaseNavBar(); applyPersonalization(); + WallpaperFolder.rotateOnLaunch(this); + + if (!UiSettings.animationsDisabled()) { + contentView.post(() -> { + DynamicAnim.springAlphaTo(contentView, 1f).start(); + DynamicAnim.springTranslationYTo(contentView, 0f).start(); + }); + } + } - contentView.post(() -> { - DynamicAnim.springAlphaTo(contentView, 1f).start(); - DynamicAnim.springTranslationYTo(contentView, 0f).start(); + /** + * Keeps the navigation and the page clear of the front camera cutout. The shell is padded by the + * cutout's safe insets (in landscape only the side with the camera gets a margin); the shell's own + * background and the wallpaper still fill the screen. Recomputed whenever the insets change, e.g. + * after rotating to the other landscape side. + */ + private void applyCutoutInsets(View shell) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return; + View bar = shell.findViewById(R.id.nav_bar_root); + View content = shell.findViewById(R.id.shell_content); + boolean splitPadding = UiSettings.useTopNavbar(this) && bar != null && content != null; + int barStart = bar != null ? bar.getPaddingLeft() : 0; + int barEnd = bar != null ? bar.getPaddingRight() : 0; + int p0l = shell.getPaddingLeft(), p0t = shell.getPaddingTop(); + int p0r = shell.getPaddingRight(), p0b = shell.getPaddingBottom(); + shell.setOnApplyWindowInsetsListener((v, insets) -> { + android.view.DisplayCutout cutout = insets.getDisplayCutout(); + int l = cutout != null ? cutout.getSafeInsetLeft() : 0; + int t = cutout != null ? cutout.getSafeInsetTop() : 0; + int r = cutout != null ? cutout.getSafeInsetRight() : 0; + int b = cutout != null ? cutout.getSafeInsetBottom() : 0; + if (splitPadding) { + // Top navbar: the bar's background still spans the screen, its items and the page move in. + bar.setPadding(barStart + l, t, barEnd + r, 0); + content.setPadding(l, 0, r, b); + } else { + v.setPadding(p0l + l, p0t + t, p0r + r, p0b + b); + } + return insets; }); + shell.requestApplyInsets(); } private void applyPersonalization() { @@ -157,58 +244,84 @@ protected boolean shouldSkipNavBar() { return false; } - private void setupBaseNavBar() { - int[] tabIds = { + /** Full-screen pages without the shell (splash, game loading) draw edge to edge on purpose. */ + protected boolean avoidsCutoutWithoutShell() { + return !(this instanceof SplashActivity); + } + + private static final int[] NAV_TAB_IDS = { R.id.nav_tab_launch, R.id.nav_tab_instances, R.id.nav_tab_about, R.id.nav_tab_settings - }; + }; + private void setupBaseNavBar() { PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - - for (int id : tabIds) { - TextView tab = findViewById(id); - if (tab == null) continue; - int color = getResources().getColor(R.color.text_secondary, getTheme()); - tab.setTextColor(color); - tab.setTypeface(tab.getTypeface(), android.graphics.Typeface.NORMAL); - TextViewCompat.setCompoundDrawableTintList(tab, ColorStateList.valueOf(color)); - } + boolean navbar = UiSettings.useTopNavbar(this); + // Over a wallpaper the chrome becomes translucent (Windows NAVBAR_BG) and the page area clear. if (pm.hasBackgroundImage()) { - View navRoot = findViewById(R.id.nav_bar_root); - if (navRoot != null) { - boolean isDark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) - == Configuration.UI_MODE_NIGHT_YES; - navRoot.setBackgroundColor(isDark - ? android.graphics.Color.argb(90, 25, 25, 25) - : android.graphics.Color.argb(110, 255, 255, 255)); - } - View navDivider = findViewById(R.id.nav_divider); - if (navDivider != null) { - boolean isDark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) - == Configuration.UI_MODE_NIGHT_YES; - navDivider.setBackgroundColor(isDark - ? android.graphics.Color.argb(40, 255, 255, 255) - : android.graphics.Color.argb(40, 0, 0, 0)); + int chrome = pm.getChromeGlassColor(this); + View root = findViewById(R.id.shell_root); + View bar = findViewById(R.id.nav_bar_root); + View content = findViewById(R.id.shell_content); + if (navbar) { + if (root != null) root.setBackgroundColor(Color.TRANSPARENT); + if (bar != null) bar.setBackgroundColor(chrome); + } else { + if (root != null) root.setBackgroundColor(chrome); + if (content != null) { + GradientDrawable frame = new GradientDrawable(); + float r = 20f * getResources().getDisplayMetrics().density; + frame.setCornerRadii(new float[]{r, r, 0, 0, 0, 0, 0, 0}); + frame.setColor(Color.TRANSPARENT); + frame.setStroke(Math.max(1, Math.round(getResources().getDisplayMetrics().density)), + ContextCompat.getColor(this, R.color.ui_border)); + content.setBackground(new android.graphics.drawable.InsetDrawable(frame, 0, 0, -4, -4)); + } } } + for (int id : NAV_TAB_IDS) styleNavTab(findViewById(id), false); + View backButton = findViewById(R.id.nav_back_button); if (backButton != null) { backButton.setOnClickListener(v -> getOnBackPressedDispatcher().onBackPressed()); - DynamicAnim.applyPressScale(backButton); + } + View forwardButton = findViewById(R.id.nav_forward_button); + if (forwardButton != null) { + forwardButton.setOnClickListener(v -> { + Intent next = sForwardHistory.pollFirst(); + if (next == null) return; + next.putExtra(EXTRA_FROM_FORWARD, true); + startActivity(next); + }); + } + refreshHistoryButtons(); + + View themeToggle = findViewById(R.id.nav_theme_toggle); + if (themeToggle instanceof ImageView) { + ThemeManager tm = new ThemeManager(this); + if (tm.canQuickToggle()) { + boolean dark = tm.getCurrentMode() == ThemeManager.MODE_DARK; + ((ImageView) themeToggle).setImageResource(dark ? R.drawable.ic_moon : R.drawable.ic_sun); + themeToggle.setVisibility(View.VISIBLE); + themeToggle.setOnClickListener(v -> new ThemeManager(this).setThemeMode( + dark ? ThemeManager.MODE_LIGHT : ThemeManager.MODE_DARK)); + } else { + themeToggle.setVisibility(View.GONE); + } } View signIn = findViewById(R.id.nav_sign_in_button); if (signIn != null) { signIn.setOnClickListener(v -> navAccountLoginLauncher.launch(new Intent(this, MsftLoginActivity.class))); - DynamicAnim.applyPressScale(signIn); } View avatarContainer = findViewById(R.id.nav_account_avatar_container); if (avatarContainer != null) { - avatarContainer.setOnClickListener(v -> startActivity(new Intent(this, AccountsActivity.class))); + avatarContainer.setOnClickListener(v -> { + if (!(this instanceof AccountsActivity)) startActivity(new Intent(this, AccountsActivity.class)); + }); DynamicAnim.applyPressScale(avatarContainer); } @@ -219,34 +332,171 @@ private void setupBaseNavBar() { startActivity(new Intent(this, NewsActivity.class)); } }); - DynamicAnim.applyPressScale(news); } - findViewById(R.id.nav_tab_launch).setOnClickListener(v -> { - if (!(this instanceof MainActivity)) { - Intent intent = new Intent(this, MainActivity.class); + bindNavTab(R.id.nav_tab_launch, MainActivity.class); + bindNavTab(R.id.nav_tab_instances, InstancesActivity.class); + bindNavTab(R.id.nav_tab_about, AboutActivity.class); + bindNavTab(R.id.nav_tab_settings, SettingsActivity.class); + + refreshBreadcrumbs(); + refreshNavAccountUI(); + refreshNewsBadge(); + } + + /** Top-level pages are switched like tabs: reuse the page if it is open instead of stacking copies. */ + private void bindNavTab(int id, Class page) { + View tab = findViewById(id); + if (tab == null) return; + tab.setOnClickListener(v -> { + if (page.isInstance(this)) return; + Intent intent = new Intent(this, page); + if (page == MainActivity.class || sLivePages.contains(page)) { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - startActivity(intent); } + startActivity(intent); }); - findViewById(R.id.nav_tab_instances).setOnClickListener(v -> { - if (!(this instanceof InstancesActivity)) { - startActivity(new Intent(this, InstancesActivity.class)); + } + + private void refreshHistoryButtons() { + View back = findViewById(R.id.nav_back_button); + if (back != null) { + boolean canBack = !isTaskRoot(); + back.setEnabled(canBack); + back.setAlpha(canBack ? 1f : 0.35f); + } + View forward = findViewById(R.id.nav_forward_button); + if (forward != null) { + boolean canForward = !sForwardHistory.isEmpty(); + forward.setEnabled(canForward); + forward.setAlpha(canForward ? 1f : 0.35f); + } + } + + /** Title of this page in the breadcrumbs. Pages with a dynamic title (a world, a mod) override it. */ + @Nullable + protected CharSequence getBreadcrumbTitle() { + NavRoutes.Route route = NavRoutes.get(getClass()); + return route != null ? getString(route.title) : getTitle(); + } + + /** Rebuilds the breadcrumbs, e.g. after the page title changed. */ + protected void refreshBreadcrumbs() { + LinearLayout crumbs = findViewById(R.id.nav_breadcrumbs); + if (crumbs == null) return; + TextView appName = findViewById(R.id.nav_app_name); + for (int i = crumbs.getChildCount() - 1; i >= 0; i--) { + if (crumbs.getChildAt(i) != appName) crumbs.removeViewAt(i); + } + if (this instanceof MainActivity) { + if (appName != null) appName.setVisibility(View.VISIBLE); + return; + } + if (appName != null) appName.setVisibility(View.GONE); + + java.util.List> chain = new java.util.ArrayList<>(); + NavRoutes.Route route = NavRoutes.get(getClass()); + Class parent = route != null ? route.parent : MainActivity.class; + while (parent != null && chain.size() < 6) { + chain.add(0, parent); + NavRoutes.Route r = NavRoutes.get(parent); + parent = r != null ? r.parent : null; + } + float density = getResources().getDisplayMetrics().density; + int muted = ContextCompat.getColor(this, R.color.text_secondary); + for (Class page : chain) { + NavRoutes.Route r = NavRoutes.get(page); + TextView crumb = makeCrumb(r != null ? getString(r.title) : page.getSimpleName(), false); + boolean reachable = r != null && (r.standalone || sLivePages.contains(page)); + if (reachable) { + crumb.setBackgroundResource(R.drawable.bg_shell_icon_button); + crumb.setOnClickListener(v -> { + Intent intent = new Intent(this, page); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + }); } - }); - findViewById(R.id.nav_tab_about).setOnClickListener(v -> { - if (!(this instanceof AboutActivity)) { - startActivity(new Intent(this, AboutActivity.class)); + crumbs.addView(crumb); + ImageView chevron = new ImageView(this); + chevron.setImageResource(R.drawable.ic_ui_chevron_right); + chevron.setImageTintList(ColorStateList.valueOf(muted)); + int size = Math.round(16 * density); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size); + lp.setMarginStart(Math.round(2 * density)); + lp.setMarginEnd(Math.round(2 * density)); + crumbs.addView(chevron, lp); + } + CharSequence title = getBreadcrumbTitle(); + crumbs.addView(makeCrumb(title != null ? title : "", true)); + } + + private TextView makeCrumb(CharSequence text, boolean current) { + TextView t = new TextView(this); + t.setText(text); + t.setSingleLine(true); + t.setTextSize(14); + t.setTypeface(androidx.core.content.res.ResourcesCompat.getFont(this, R.font.misans), + current ? Typeface.BOLD : Typeface.NORMAL); + t.setTextColor(ContextCompat.getColor(this, current ? R.color.text_primary : R.color.text_secondary)); + float density = getResources().getDisplayMetrics().density; + int padH = Math.round(6 * density); + t.setPadding(padH, 0, padH, 0); + t.setGravity(android.view.Gravity.CENTER_VERTICAL); + t.setMinHeight(Math.round(40 * density)); + return t; + } + + private void styleNavTab(@Nullable View view, boolean active) { + if (!(view instanceof TextView)) return; + TextView tab = (TextView) view; + boolean navbar = UiSettings.useTopNavbar(this); + PersonalizationManager pm = new PersonalizationManager(this); + boolean dark = pm.isDarkMode(this); + int accent = pm.getAccentColor(dark); + float density = getResources().getDisplayMetrics().density; + if (navbar) { + int text = ContextCompat.getColor(this, R.color.text_primary); + tab.setTextColor(text); + tab.setTypeface(tab.getTypeface(), active ? Typeface.BOLD : Typeface.NORMAL); + TextViewCompat.setCompoundDrawableTintList(tab, ColorStateList.valueOf(text)); + if (active) { + GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(12 * density); + bg.setColor(ContextCompat.getColor(this, R.color.surface_variant)); + tab.setBackground(bg); + } else { + tab.setBackgroundResource(R.drawable.bg_shell_icon_button); } - }); - findViewById(R.id.nav_tab_settings).setOnClickListener(v -> { - if (!(this instanceof SettingsActivity)) { - startActivity(new Intent(this, SettingsActivity.class)); + } else { + int icon = active ? ThemePalette.foreground(accent, dark) : ContextCompat.getColor(this, R.color.ui_muted); + TextViewCompat.setCompoundDrawableTintList(tab, ColorStateList.valueOf(icon)); + if (active) { + GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(12 * density); + bg.setColor(ThemePalette.withAlpha(accent, 0.10f)); + tab.setBackground(bg); + } else { + tab.setBackgroundResource(R.drawable.bg_shell_icon_button); } - }); + } + } - refreshNavAccountUI(); - refreshNewsBadge(); + /** Moves the 4dp accent pill next to the active rail item (Windows Sidebar indicator). */ + private void placeRailIndicator() { + View indicator = findViewById(R.id.nav_rail_indicator); + if (indicator == null) return; + View tab = activeNavTabId != View.NO_ID ? findViewById(activeNavTabId) : null; + if (tab == null) { + indicator.setVisibility(View.INVISIBLE); + return; + } + indicator.setBackgroundTintList(ColorStateList.valueOf(new PersonalizationManager(this).getAccentColor( + new PersonalizationManager(this).isDarkMode(this)))); + tab.post(() -> { + float y = tab.getTop() + ((View) tab.getParent()).getTop() + (tab.getHeight() - indicator.getHeight()) / 2f; + indicator.setTranslationY(y); + indicator.setVisibility(View.VISIBLE); + }); } private void refreshNewsBadge() { @@ -268,6 +518,11 @@ private void applyNewsBadge(NewsFeed feed) { } } + /** The wallpaper was replaced in place on this page; do not recreate it for that change on resume. */ + public void markPersonalizationApplied() { + appliedPersonalizationGeneration = PersonalizationManager.getChangeGeneration(); + } + protected void onNewsChanged() { } @@ -364,29 +619,9 @@ protected void onNavAccountChanged() { protected void setActiveNavTab(int activeTabId) { if (!navBarInjected) return; - int[] tabIds = { - R.id.nav_tab_launch, R.id.nav_tab_instances, - R.id.nav_tab_about, R.id.nav_tab_settings - }; - - PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - - for (int id : tabIds) { - TextView tab = findViewById(id); - if (tab == null) continue; - int color; - if (id == activeTabId) { - color = accent != 0 ? accent : getResources().getColor(R.color.on_surface, getTheme()); - tab.setTextColor(color); - tab.setTypeface(tab.getTypeface(), android.graphics.Typeface.BOLD); - } else { - color = getResources().getColor(R.color.text_secondary, getTheme()); - tab.setTextColor(color); - tab.setTypeface(tab.getTypeface(), android.graphics.Typeface.NORMAL); - } - TextViewCompat.setCompoundDrawableTintList(tab, ColorStateList.valueOf(color)); - } + activeNavTabId = activeTabId; + for (int id : NAV_TAB_IDS) styleNavTab(findViewById(id), id == activeTabId); + placeRailIndicator(); } @Override @@ -408,12 +643,20 @@ protected void onResume() { super.onResume(); int currentGen = ThemeManager.getThemeChangeGeneration(); int currentPGen = PersonalizationManager.getChangeGeneration(); - if (appliedThemeGeneration != currentGen || appliedPersonalizationGeneration != currentPGen) { + if (new ThemeManager(this).refreshScheduledMode()) currentGen = ThemeManager.getThemeChangeGeneration(); + int currentLayout = UiSettings.getLayoutGeneration(); + if (appliedThemeGeneration != currentGen || appliedPersonalizationGeneration != currentPGen + || appliedLayoutGeneration != currentLayout) { appliedThemeGeneration = currentGen; appliedPersonalizationGeneration = currentPGen; + appliedLayoutGeneration = currentLayout; recreate(); return; } + UiSettings.refreshAnimations(this); + refreshHistoryButtons(); + scheduleHandler.removeCallbacks(scheduleCheck); + scheduleHandler.postDelayed(scheduleCheck, 60_000L); getDelegate().applyDayNight(); hideSystemUI(); int currentEffects = PersonalizationManager.getBackgroundEffectGeneration(); @@ -438,6 +681,7 @@ protected void onStop() { @Override protected void onPause() { + scheduleHandler.removeCallbacks(scheduleCheck); hideSystemUI(); super.onPause(); } @@ -445,6 +689,7 @@ protected void onPause() { @Override protected void onDestroy() { navAccountExecutor.shutdownNow(); + if (isFinishing()) sLivePages.remove(getClass()); super.onDestroy(); } @@ -520,6 +765,13 @@ private void applyExitTransition(boolean keepBackground) { @Override public void finish() { + if (finishingByBack && navBarInjected && !isTaskRoot()) { + Intent again = new Intent(getIntent()); + again.removeExtra(EXTRA_FROM_FORWARD); + again.setFlags(0); + sForwardHistory.addFirst(again); + while (sForwardHistory.size() > 20) sForwardHistory.pollLast(); + } boolean keepBackground = !isTaskRoot() && hasLauncherBackground(); if (!isTaskRoot()) LauncherBackgroundController.navigating(this); super.finish(); diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/ContentDetailsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/ContentDetailsActivity.java index 534f8f746..b4f690034 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/ContentDetailsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/ContentDetailsActivity.java @@ -196,15 +196,7 @@ private void bindData() { info.append(" · ").append(content.dateModified, 0, 10); } metadata.setText(info.toString()); - org.levimc.launcher.util.PersonalizationManager personalization = - new org.levimc.launcher.util.PersonalizationManager(this); - if (personalization.hasCustomAccent()) { - btnInstall.setBackgroundTintList(android.content.res.ColorStateList.valueOf(personalization.getAccentColor())); - int onAccent = android.graphics.Color.luminance(personalization.getAccentColor()) > 0.42f - ? android.graphics.Color.BLACK : android.graphics.Color.WHITE; - btnInstall.setTextColor(onAccent); - btnInstall.setIconTint(android.content.res.ColorStateList.valueOf(onAccent)); - } + org.levimc.launcher.ui.widget.UiKit.applyPrimary(btnInstall); if (content.logo != null && content.logo.thumbnailUrl != null) { Glide.with(this) .load(content.logo.thumbnailUrl) diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/ContentListActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/ContentListActivity.java index 4f4ba4a43..e139a2b3a 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/ContentListActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/ContentListActivity.java @@ -8,6 +8,7 @@ import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; +import android.view.Menu; import android.view.View; import android.widget.EditText; import android.widget.RadioButton; @@ -20,6 +21,7 @@ import androidx.annotation.NonNull; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.LinearLayoutManager; @@ -52,6 +54,8 @@ import java.io.OutputStream; import java.io.File; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -70,6 +74,8 @@ public class ContentListActivity extends BaseActivity { public static final int TYPE_SERVERS = 5; private static final String STATE_SELECTION_MODE = "selection_mode"; private static final String STATE_SELECTED_PATHS = "selected_paths"; + private static final int SORT_NAME = 0; + private static final int SORT_TIME = 1; private ActivityContentListBinding binding; private ContentManager contentManager; @@ -102,6 +108,8 @@ public class ContentListActivity extends BaseActivity { private List allWorlds = new ArrayList<>(); private List allPacks = new ArrayList<>(); private List allServers = new ArrayList<>(); + private int sortField = SORT_NAME; + private boolean sortAscending = true; private org.levimc.launcher.ui.dialogs.LoadingDialog progressDialog; @@ -248,7 +256,8 @@ private void setupUI() { switch (contentType) { case TYPE_WORLDS: - binding.titleText.setText(getString(R.string.worlds_title)); + binding.titleText.setText(getString(R.string.content_ui_worlds_title)); + binding.searchEditText.setHint(R.string.content_ui_search_worlds); binding.customFlatButton.setVisibility(View.VISIBLE); binding.selectButton.setVisibility(View.VISIBLE); setupWorldsRecyclerView(); @@ -269,13 +278,13 @@ private void setupUI() { setupPacksRecyclerView(); break; case TYPE_SCREENSHOTS: - binding.titleText.setText(getString(R.string.screenshots_category)); - binding.searchEditText.setVisibility(View.GONE); + binding.titleText.setText(getString(R.string.content_ui_screenshots)); + binding.searchRow.setVisibility(View.GONE); setupScreenshotsRecyclerView(); break; case TYPE_SERVERS: binding.titleText.setText(getString(R.string.servers_category)); - binding.customFlatButton.setText(getString(R.string.quick_launch_add_server)); + binding.customFlatButton.setText(getString(R.string.content_ui_add_server)); binding.customFlatButton.setVisibility(View.VISIBLE); setupServersRecyclerView(); break; @@ -286,15 +295,107 @@ private void setupUI() { else openCustomFlatWorld(); }); binding.selectButton.setOnClickListener(v -> enterSelectionMode()); + binding.selectionCancelButton.setOnClickListener(v -> exitSelectionMode()); + binding.refreshButton.setOnClickListener(v -> loadContent()); + binding.sortButton.setOnClickListener(v -> showSortMenu()); binding.selectAllButton.setOnClickListener(v -> selectAllVisible()); binding.batchExportButton.setOnClickListener(v -> startBatchExport()); binding.batchTransferButton.setOnClickListener(v -> showBatchTransferDialog()); binding.batchDeleteButton.setOnClickListener(v -> showBatchDeleteDialog()); setupSearchFilter(); + loadSortPreference(); + updateSortLabel(); + updateInfoStrip(); updateSelectionToolbar(); } + @Override + protected CharSequence getBreadcrumbTitle() { + // Called from BaseActivity while the shell is built, before contentType is assigned. + int type = getIntent() != null ? getIntent().getIntExtra(EXTRA_CONTENT_TYPE, TYPE_WORLDS) : TYPE_WORLDS; + return switch (type) { + case TYPE_SKIN_PACKS -> getString(R.string.skin_packs_category); + case TYPE_RESOURCE_PACKS -> getString(R.string.resource_packs_category); + case TYPE_BEHAVIOR_PACKS -> getString(R.string.behavior_packs_category); + case TYPE_SCREENSHOTS -> getString(R.string.content_ui_screenshots); + case TYPE_SERVERS -> getString(R.string.servers_category); + default -> getString(R.string.worlds_category); + }; + } + + private void updateInfoStrip() { + GameVersion version = versionManager.getSelectedVersion(); + binding.stripInstanceValue.setText(version != null + ? ContentManagementActivity.instanceName(version) : getString(R.string.not_found_version)); + FeatureSettings.StorageType type = currentStorageType; + if (version != null) type = LauncherStorage.normalizeContentStorageType(type, version.versionIsolation); + String label = switch (type) { + case INTERNAL -> getString(R.string.storage_internal); + case EXTERNAL -> getString(R.string.storage_external); + case VERSION_ISOLATION_INTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_internal) + ")"; + case VERSION_ISOLATION, VERSION_ISOLATION_EXTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_external) + ")"; + }; + binding.stripStorageValue.setText(label); + } + + private boolean supportsTimeSort() { + return contentType == TYPE_WORLDS || isPackType(); + } + + private void loadSortPreference() { + SharedPreferences prefs = getSharedPreferences("content_management", MODE_PRIVATE); + sortField = prefs.getInt("sort_field_" + contentType, SORT_NAME); + if (!supportsTimeSort()) sortField = SORT_NAME; + sortAscending = prefs.getBoolean("sort_asc_" + contentType, true); + } + + private void updateSortLabel() { + binding.sortButton.setText(getString(R.string.content_ui_sort_format, + getString(sortField == SORT_TIME ? R.string.content_ui_sort_time : R.string.content_ui_sort_name), + getString(sortAscending ? R.string.content_ui_sort_asc : R.string.content_ui_sort_desc))); + } + + private void showSortMenu() { + PopupMenu menu = new PopupMenu(this, binding.sortButton); + int fields = supportsTimeSort() ? 2 : 1; + for (int field = 0; field < fields; field++) { + for (int asc = 1; asc >= 0; asc--) { + int id = field * 2 + (1 - asc); + String label = getString(R.string.content_ui_sort_format, + getString(field == SORT_TIME ? R.string.content_ui_sort_time : R.string.content_ui_sort_name), + getString(asc == 1 ? R.string.content_ui_sort_asc : R.string.content_ui_sort_desc)); + menu.getMenu().add(Menu.NONE, id, id, label); + } + } + menu.setOnMenuItemClickListener(item -> { + sortField = item.getItemId() / 2; + sortAscending = item.getItemId() % 2 == 0; + getSharedPreferences("content_management", MODE_PRIVATE).edit() + .putInt("sort_field_" + contentType, sortField) + .putBoolean("sort_asc_" + contentType, sortAscending) + .apply(); + updateSortLabel(); + filterContent(binding.searchEditText.getText().toString()); + return true; + }); + menu.show(); + } + + private void applySort(List items, java.util.function.Function name, java.util.function.Function file) { + Comparator comparator; + if (sortField == SORT_TIME && file != null) { + comparator = Comparator.comparingLong(item -> { + File f = file.apply(item); + return f != null ? f.lastModified() : 0L; + }); + } else { + comparator = (a, b) -> String.CASE_INSENSITIVE_ORDER.compare( + name.apply(a) != null ? name.apply(a) : "", name.apply(b) != null ? name.apply(b) : ""); + } + items.sort(sortAscending ? comparator : Collections.reverseOrder(comparator)); + } + private void setupSearchFilter() { binding.searchEditText.addTextChangedListener(new TextWatcher() { @Override @@ -318,6 +419,7 @@ private void filterContent(String query) { List filtered = lowerQuery.isEmpty() ? new ArrayList<>(allWorlds) : allWorlds.stream() .filter(world -> world.getWorldName() != null && world.getWorldName().toLowerCase().contains(lowerQuery)) .collect(Collectors.toList()); + applySort(filtered, WorldItem::getWorldName, WorldItem::getFile); worldsAdapter.updateWorlds(filtered); updateListState(filtered.size(), allWorlds.size(), rawQuery); } else if (contentType == TYPE_SERVERS && serversAdapter != null) { @@ -325,12 +427,14 @@ private void filterContent(String query) { .filter(server -> (server.name != null && server.name.toLowerCase().contains(lowerQuery)) || (server.ip != null && server.ip.toLowerCase().contains(lowerQuery))) .collect(Collectors.toList()); + applySort(filtered, server -> server.name, null); serversAdapter.updateData(filtered); updateListState(filtered.size(), allServers.size(), rawQuery); } else if (isPackType() && packsAdapter != null) { List filtered = lowerQuery.isEmpty() ? new ArrayList<>(allPacks) : allPacks.stream() .filter(pack -> pack.getPackName() != null && pack.getPackName().toLowerCase().contains(lowerQuery)) .collect(Collectors.toList()); + applySort(filtered, ResourcePackItem::getPackName, ResourcePackItem::getFile); packsAdapter.updateResourcePacks(filtered); updateListState(filtered.size(), allPacks.size(), rawQuery); } @@ -1594,6 +1698,7 @@ protected void onResume() { if (skipInitialResumeRefresh) { skipInitialResumeRefresh = false; } else { + updateInfoStrip(); loadContent(); } } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/ContentManagementActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/ContentManagementActivity.java index dd59637ff..581750aa7 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/ContentManagementActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/ContentManagementActivity.java @@ -4,27 +4,36 @@ import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; -import android.view.View; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; +import android.os.Handler; +import android.os.Looper; +import android.view.Menu; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.widget.PopupMenu; import org.levimc.launcher.R; import org.levimc.launcher.core.content.ContentImporter; import org.levimc.launcher.core.content.ContentManager; +import org.levimc.launcher.core.content.ResourcePackManager; +import org.levimc.launcher.core.content.ScreenshotManager; +import org.levimc.launcher.core.content.ServerManager; import org.levimc.launcher.core.versions.GameVersion; import org.levimc.launcher.core.versions.VersionManager; import org.levimc.launcher.databinding.ActivityContentManagementBinding; import org.levimc.launcher.settings.FeatureSettings; import org.levimc.launcher.ui.animation.DynamicAnim; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.LauncherStorage; import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + public class ContentManagementActivity extends BaseActivity { private static final String PREFS_NAME = "content_management"; @@ -38,6 +47,9 @@ public class ContentManagementActivity extends BaseActivity { private SharedPreferences prefs; private ActivityResultLauncher importLauncher; private org.levimc.launcher.ui.dialogs.LoadingDialog progressDialog; + private final ExecutorService countExecutor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final AtomicLong countGeneration = new AtomicLong(); private void showProgressDialog(String message) { if (progressDialog == null) { @@ -111,9 +123,19 @@ private void saveStorageType() { private void setupUI() { binding.editOptionsButton.setOnClickListener(v -> openOptionsEditor()); binding.importContentButton.setOnClickListener(v -> startImport()); - - setupStorageSpinner(); + binding.refreshButton.setOnClickListener(v -> { + updateStorageDirectories(); + contentManager.refreshContent(); + }); + + setupStorageSelector(); setupCategoryButtons(); + contentManager.getCountsLiveData().observe(this, counts -> { + if (counts == null) return; + binding.worldsCount.setText(String.valueOf(counts.worlds)); + binding.resourcePacksCount.setText(String.valueOf(counts.resourcePacks)); + binding.skinPacksCount.setText(String.valueOf(counts.skinPacks)); + }); } private void startImport() { @@ -146,6 +168,7 @@ public void onSuccess(String message) { hideProgressDialog(); Toast.makeText(ContentManagementActivity.this, message, Toast.LENGTH_SHORT).show(); contentManager.refreshContent(); + updateStorageDirectories(); }); } @@ -168,52 +191,54 @@ private File getPackDirectory(String packType) { return gameDataDir == null ? null : new File(gameDataDir, packType); } - private void setupStorageSpinner() { - String[] storageOptions = { - getString(R.string.storage_internal), - getString(R.string.storage_external), - getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_internal) + ")", - getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_external) + ")" - }; - - ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.spinner_item, storageOptions); - adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); - binding.storageTypeSpinner.setAdapter(adapter); - binding.storageTypeSpinner.setPopupBackgroundResource(R.drawable.bg_popup_menu_rounded); - DynamicAnim.applyPressScale(binding.storageTypeSpinner); - - int currentSelection = switch (currentStorageType) { - case INTERNAL -> 0; - case EXTERNAL -> 1; - case VERSION_ISOLATION_INTERNAL -> 2; - case VERSION_ISOLATION, VERSION_ISOLATION_EXTERNAL -> 3; - }; - binding.storageTypeSpinner.setSelection(currentSelection); - - binding.storageTypeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - FeatureSettings.StorageType newType = switch (position) { - case 0 -> FeatureSettings.StorageType.INTERNAL; - case 1 -> FeatureSettings.StorageType.EXTERNAL; - case 2 -> FeatureSettings.StorageType.VERSION_ISOLATION_INTERNAL; - case 3 -> FeatureSettings.StorageType.VERSION_ISOLATION_EXTERNAL; - default -> FeatureSettings.StorageType.VERSION_ISOLATION_EXTERNAL; - }; - + private static final FeatureSettings.StorageType[] STORAGE_CHOICES = { + FeatureSettings.StorageType.INTERNAL, + FeatureSettings.StorageType.EXTERNAL, + FeatureSettings.StorageType.VERSION_ISOLATION_INTERNAL, + FeatureSettings.StorageType.VERSION_ISOLATION_EXTERNAL + }; + + private void setupStorageSelector() { + updateStorageLabel(); + binding.storageValue.setOnClickListener(v -> { + PopupMenu menu = new PopupMenu(this, binding.storageValue); + for (int i = 0; i < STORAGE_CHOICES.length; i++) { + menu.getMenu().add(Menu.NONE, i, i, storageLabel(STORAGE_CHOICES[i])); + } + menu.setOnMenuItemClickListener(item -> { + FeatureSettings.StorageType newType = STORAGE_CHOICES[item.getItemId()]; if (newType != currentStorageType) { currentStorageType = newType; saveStorageType(); + updateStorageLabel(); updateStorageDirectories(); } - } - - @Override - public void onNothingSelected(AdapterView parent) {} + return true; + }); + menu.show(); }); } + private void updateStorageLabel() { + binding.storageValue.setText(storageLabel(currentStorageType)); + } + + private String storageLabel(FeatureSettings.StorageType type) { + return switch (type) { + case INTERNAL -> getString(R.string.storage_internal); + case EXTERNAL -> getString(R.string.storage_external); + case VERSION_ISOLATION_INTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_internal) + ")"; + case VERSION_ISOLATION, VERSION_ISOLATION_EXTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_external) + ")"; + }; + } + private void setupCategoryButtons() { + UiKit.tintIconChip(binding.worldsChip, binding.worldsIcon, UiKit.BLUE); + UiKit.tintIconChip(binding.resourcePacksChip, binding.resourcePacksIcon, UiKit.PURPLE); + UiKit.tintIconChip(binding.behaviorPacksChip, binding.behaviorPacksIcon, UiKit.ORANGE); + UiKit.tintIconChip(binding.skinPacksChip, binding.skinPacksIcon, UiKit.PINK); + UiKit.tintIconChip(binding.serversChip, binding.serversIcon, UiKit.INDIGO); + UiKit.tintIconChip(binding.screenshotsChip, binding.screenshotsIcon, UiKit.TEAL); binding.worldsButton.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_WORLDS)); binding.skinPacksButton.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_SKIN_PACKS)); binding.resourcePacksButton.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_RESOURCE_PACKS)); @@ -245,14 +270,29 @@ private File getWorldsDirectory() { private void loadCurrentVersion() { GameVersion currentVersion = versionManager.getSelectedVersion(); if (currentVersion != null) { - binding.versionText.setText(currentVersion.displayName); + binding.instanceText.setText(instanceName(currentVersion)); + binding.versionText.setText(currentVersion.versionCode != null && !currentVersion.versionCode.isEmpty() + ? currentVersion.versionCode : "\u2014"); updateStorageDirectories(); } else { - binding.versionText.setText(getString(R.string.not_found_version)); + binding.instanceText.setText(getString(R.string.not_found_version)); + binding.versionText.setText("\u2014"); Toast.makeText(this, R.string.dialog_title_no_version, Toast.LENGTH_SHORT).show(); } } + /** Instance name without the " (version)" suffix that display names carry. */ + static String instanceName(GameVersion version) { + String name = version.displayName != null && !version.displayName.isEmpty() + ? version.displayName : version.directoryName; + if (name == null) return ""; + String suffix = version.versionCode != null ? " (" + version.versionCode + ")" : null; + if (suffix != null && name.endsWith(suffix) && name.length() > suffix.length()) { + name = name.substring(0, name.length() - suffix.length()); + } + return name; + } + private void updateStorageDirectories() { GameVersion currentVersion = versionManager.getSelectedVersion(); if (currentVersion == null) return; @@ -283,6 +323,46 @@ private void updateStorageDirectories() { contentManager.configureStorageDirectories(worldsDir, resourcePacksDir, behaviorPacksDir, skinPacksDir, screenshotsDir, minecraftPeDir); contentManager.refreshCounts(); + refreshExtraCounts(behaviorPacksDir, screenshotsDir, minecraftPeDir); + } + + /** Behavior packs, screenshots and servers are not in ContentManager's counts; count them off the UI thread. */ + private void refreshExtraCounts(File behaviorPacksDir, File screenshotsDir, File minecraftPeDir) { + long generation = countGeneration.incrementAndGet(); + if (countExecutor.isShutdown()) return; + android.content.Context appContext = getApplicationContext(); + countExecutor.execute(() -> { + int behaviors = 0; + int screenshots = 0; + int servers = 0; + try { + ResourcePackManager packs = new ResourcePackManager(appContext); + packs.setPackDirectories(null, behaviorPacksDir, null); + behaviors = packs.getBehaviorPacks().size(); + } catch (Exception ignored) { + } + try { + ScreenshotManager shots = new ScreenshotManager(); + shots.setScreenshotsDirectory(screenshotsDir); + screenshots = shots.getScreenshots().size(); + } catch (Exception ignored) { + } + try { + ServerManager serverManager = new ServerManager(); + serverManager.setMinecraftPeDirectory(minecraftPeDir); + servers = serverManager.getServers().size(); + } catch (Exception ignored) { + } + int behaviorCount = behaviors; + int screenshotCount = screenshots; + int serverCount = servers; + mainHandler.post(() -> { + if (generation != countGeneration.get() || isFinishing() || isDestroyed()) return; + binding.behaviorPacksCount.setText(String.valueOf(behaviorCount)); + binding.screenshotsCount.setText(String.valueOf(screenshotCount)); + binding.serversCount.setText(String.valueOf(serverCount)); + }); + }); } private void openOptionsEditor() { @@ -292,12 +372,7 @@ private void openOptionsEditor() { return; } - String storageLabel = switch (currentStorageType) { - case INTERNAL -> getString(R.string.storage_internal); - case EXTERNAL -> getString(R.string.storage_external); - case VERSION_ISOLATION_INTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_internal) + ")"; - case VERSION_ISOLATION, VERSION_ISOLATION_EXTERNAL -> getString(R.string.storage_version_isolation) + " (" + getString(R.string.storage_external) + ")"; - }; + String storageLabel = storageLabel(currentStorageType); Intent intent = new Intent(this, OptionsEditorActivity.class); intent.putExtra(OptionsEditorActivity.EXTRA_OPTIONS_PATH, optionsFile.getAbsolutePath()); @@ -358,6 +433,7 @@ protected void onResume() { @Override protected void onDestroy() { super.onDestroy(); + countExecutor.shutdownNow(); if (contentImporter != null) { contentImporter.shutdown(); } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/CurseForgeActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/CurseForgeActivity.java index a2f5522a6..5cc519be8 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/CurseForgeActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/CurseForgeActivity.java @@ -1,36 +1,24 @@ package org.levimc.launcher.ui.activities; import android.content.Intent; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.util.TypedValue; import android.view.View; -import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.widget.EditText; -import android.widget.PopupMenu; import android.widget.ProgressBar; -import android.widget.RadioButton; -import android.widget.RadioGroup; import android.widget.TextView; -import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.button.MaterialButton; - import org.levimc.launcher.R; import org.levimc.launcher.core.curseforge.CurseForgeClient; import org.levimc.launcher.core.curseforge.models.Content; import org.levimc.launcher.core.curseforge.models.ContentSearchResponse; import org.levimc.launcher.ui.adapter.CurseForgeContentAdapter; -import org.levimc.launcher.util.PersonalizationManager; +import org.levimc.launcher.ui.widget.CatalogSelect; import org.levimc.launcher.util.UIHelper; import java.util.ArrayList; @@ -42,18 +30,25 @@ public class CurseForgeActivity extends BaseActivity { private static final int PAGE_SIZE = 20; private EditText searchBox; - private RadioGroup categoryGroup; - private MaterialButton btnSort; + private CatalogSelect classSelect; + private CatalogSelect versionSelect; + private CatalogSelect sortSelect; private RecyclerView recyclerView; private ProgressBar loadingProgress; + private View emptyState; private TextView emptyMessage; + private View retryButton; private CurseForgeContentAdapter adapter; private CurseForgeClient client; private final Handler handler = new Handler(Looper.getMainLooper()); private int currentPage = 1; private int totalPages = 1; private int requestSequence; + private Category currentCategory; private SortOption currentSort; + /** Empty means all versions. */ + private String currentVersion = ""; + private final List gameVersions = new ArrayList<>(); private static class Category { final String name; @@ -88,33 +83,30 @@ protected void onCreate(Bundle savedInstanceState) { setupData(); initViews(); loadContent(); + loadGameVersions(); } private void setupData() { - categories.add(new Category("All", 0)); + categories.add(new Category(getString(R.string.catalog_cf_all_classes), 0)); categories.add(new Category("Addons", 4984)); categories.add(new Category("Maps", 6913)); categories.add(new Category("Skins", 6925)); categories.add(new Category("Textures", 6929)); categories.add(new Category("Scripts", 6940)); - sortOptions.add(new SortOption("Popular", CurseForgeClient.SORT_POPULARITY, "desc")); - sortOptions.add(new SortOption("Downloads", CurseForgeClient.SORT_TOTAL_DOWNLOADS, "desc")); - sortOptions.add(new SortOption("Recently updated", CurseForgeClient.SORT_LAST_UPDATED, "desc")); - sortOptions.add(new SortOption("Name", CurseForgeClient.SORT_NAME, "asc")); - } - - private int dp(float value) { - return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics())); + sortOptions.add(new SortOption(getString(R.string.catalog_cf_sort_popular), CurseForgeClient.SORT_POPULARITY, "desc")); + sortOptions.add(new SortOption(getString(R.string.catalog_cf_sort_downloads), CurseForgeClient.SORT_TOTAL_DOWNLOADS, "desc")); + sortOptions.add(new SortOption(getString(R.string.catalog_cf_sort_updated), CurseForgeClient.SORT_LAST_UPDATED, "desc")); + sortOptions.add(new SortOption(getString(R.string.catalog_cf_sort_name), CurseForgeClient.SORT_NAME, "asc")); } private void initViews() { searchBox = findViewById(R.id.search_box); - categoryGroup = findViewById(R.id.category_group); - btnSort = findViewById(R.id.btn_sort); recyclerView = findViewById(R.id.mods_recycler); loadingProgress = findViewById(R.id.loading_progress); + emptyState = findViewById(R.id.empty_state); emptyMessage = findViewById(R.id.empty_message); + retryButton = findViewById(R.id.cf_retry); adapter = new CurseForgeContentAdapter(this::onContentClick, new CurseForgeContentAdapter.OnPageChangeListener() { @Override @@ -139,100 +131,102 @@ public void onPrevPage() { searchBox.setOnEditorActionListener((view, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_SEARCH) { - currentPage = 1; - loadContent(); - UIHelper.hideKeyboard(this); - searchBox.clearFocus(); + runSearch(); return true; } return false; }); + findViewById(R.id.btn_search).setOnClickListener(v -> runSearch()); + retryButton.setOnClickListener(v -> { + loadContent(); + if (gameVersions.isEmpty()) loadGameVersions(); + }); - PersonalizationManager personalization = new PersonalizationManager(this); - int accentColor = personalization.hasCustomAccent() - ? personalization.getAccentColor() - : ContextCompat.getColor(this, R.color.primary); - int onSurface = ContextCompat.getColor(this, R.color.on_surface); - int surfaceVariant = ContextCompat.getColor(this, R.color.surface_variant); - int textOnAccent = Color.luminance(accentColor) > 0.42f ? Color.BLACK : Color.WHITE; - - ColorStateList chipText = new ColorStateList( - new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, - new int[]{textOnAccent, onSurface}); - - for (int i = 0; i < categories.size(); i++) { - Category category = categories.get(i); - RadioButton chip = new RadioButton(this); - chip.setId(View.generateViewId()); - chip.setTag(category); - chip.setText(category.name); - chip.setTextColor(chipText); - chip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f); - chip.setAllCaps(false); - chip.setButtonDrawable(android.R.color.transparent); - chip.setGravity(android.view.Gravity.CENTER); - chip.setMinHeight(dp(32)); - chip.setMinimumWidth(0); - chip.setPadding(dp(12), dp(6), dp(12), dp(6)); - - GradientDrawable checked = new GradientDrawable(); - checked.setCornerRadius(dp(12)); - checked.setColor(accentColor); - GradientDrawable unchecked = new GradientDrawable(); - unchecked.setCornerRadius(dp(12)); - unchecked.setColor(surfaceVariant); - StateListDrawable state = new StateListDrawable(); - state.addState(new int[]{android.R.attr.state_checked}, checked); - state.addState(new int[]{}, unchecked); - chip.setBackground(state); - - RadioGroup.LayoutParams params = new RadioGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, dp(32)); - params.setMarginEnd(dp(7)); - categoryGroup.addView(chip, params); - if (i == 0) chip.setChecked(true); - } - - categoryGroup.setOnCheckedChangeListener((group, checkedId) -> { + List classNames = new ArrayList<>(); + for (Category category : categories) classNames.add(category.name); + currentCategory = categories.get(0); + classSelect = new CatalogSelect(findViewById(R.id.cf_select_class), getString(R.string.catalog_cf_class)); + classSelect.setItems(classNames, 0); + classSelect.setOnPick(index -> { + currentCategory = categories.get(index); currentPage = 1; loadContent(); }); + List sortNames = new ArrayList<>(); + for (SortOption option : sortOptions) sortNames.add(option.name); currentSort = sortOptions.get(0); - btnSort.setText(currentSort.name); - btnSort.setOnClickListener(view -> { - PopupMenu menu = new PopupMenu(this, view); - for (int i = 0; i < sortOptions.size(); i++) { - menu.getMenu().add(0, i, i, sortOptions.get(i).name); + sortSelect = new CatalogSelect(findViewById(R.id.btn_sort), getString(R.string.catalog_cf_sort)); + sortSelect.setItems(sortNames, 0); + sortSelect.setOnPick(index -> { + currentSort = sortOptions.get(index); + currentPage = 1; + loadContent(); + }); + + versionSelect = new CatalogSelect(findViewById(R.id.cf_select_version), getString(R.string.catalog_cf_game_version)); + bindVersions(); + versionSelect.setOnPick(index -> { + currentVersion = index == 0 ? "" : gameVersions.get(index - 1); + currentPage = 1; + loadContent(); + }); + } + + private void runSearch() { + currentPage = 1; + loadContent(); + UIHelper.hideKeyboard(this); + searchBox.clearFocus(); + } + + private void bindVersions() { + List items = new ArrayList<>(); + items.add(getString(R.string.catalog_cf_all_versions)); + items.addAll(gameVersions); + int index = currentVersion.isEmpty() ? 0 : gameVersions.indexOf(currentVersion) + 1; + versionSelect.setItems(items, Math.max(0, index)); + versionSelect.setEnabled(!gameVersions.isEmpty()); + } + + private void loadGameVersions() { + client.getGameVersions(new CurseForgeClient.CurseForgeCallback>() { + @Override + public void onSuccess(List result) { + handler.post(() -> { + if (isFinishing() || isDestroyed()) return; + gameVersions.clear(); + if (result != null) gameVersions.addAll(result); + bindVersions(); + }); + } + + @Override + public void onError(Throwable error) { + // The version filter stays at all versions (disabled); searching still works. } - menu.setOnMenuItemClickListener(item -> { - currentSort = sortOptions.get(item.getItemId()); - btnSort.setText(currentSort.name); - currentPage = 1; - loadContent(); - return true; - }); - menu.show(); }); } + private void showEmpty(CharSequence message, boolean retry) { + emptyMessage.setText(message); + retryButton.setVisibility(retry ? View.VISIBLE : View.GONE); + emptyState.setVisibility(View.VISIBLE); + } + private void loadContent() { String query = searchBox.getText().toString().trim(); - int checkedId = categoryGroup.getCheckedRadioButtonId(); - Category category = null; - if (checkedId != -1) { - View selected = categoryGroup.findViewById(checkedId); - if (selected != null) category = (Category) selected.getTag(); - } + Category category = currentCategory; + String version = currentVersion; SortOption sort = currentSort; int request = ++requestSequence; int requestedPage = currentPage; loadingProgress.setVisibility(View.VISIBLE); - emptyMessage.setVisibility(View.GONE); + emptyState.setVisibility(View.GONE); recyclerView.setVisibility(adapter.getItemCount() > 0 ? View.VISIBLE : View.INVISIBLE); - client.searchContent(query, category != null ? category.id : 0, "", + client.searchContent(query, category != null ? category.id : 0, version, (requestedPage - 1) * PAGE_SIZE, PAGE_SIZE, sort != null ? sort.field : CurseForgeClient.SORT_POPULARITY, sort != null ? sort.order : "desc", @@ -250,14 +244,13 @@ public void onSuccess(ContentSearchResponse result) { } adapter.setContents(result.data, requestedPage, totalPages); boolean empty = result.data.isEmpty(); - emptyMessage.setText(R.string.no_mods_found); - emptyMessage.setVisibility(empty ? View.VISIBLE : View.GONE); + if (empty) showEmpty(getString(R.string.catalog_cf_no_results), false); recyclerView.setVisibility(empty ? View.GONE : View.VISIBLE); if (!empty) recyclerView.scrollToPosition(0); } else { adapter.setContents(Collections.emptyList(), 1, 1); recyclerView.setVisibility(View.GONE); - emptyMessage.setVisibility(View.VISIBLE); + showEmpty(getString(R.string.catalog_cf_no_results), false); } }); } @@ -269,8 +262,7 @@ public void onError(Throwable error) { loadingProgress.setVisibility(View.GONE); if (adapter.getItemCount() == 0) { recyclerView.setVisibility(View.GONE); - emptyMessage.setText(getString(R.string.error_message_format, error.getMessage())); - emptyMessage.setVisibility(View.VISIBLE); + showEmpty(getString(R.string.error_message_format, error.getMessage()), true); } else { recyclerView.setVisibility(View.VISIBLE); android.widget.Toast.makeText(CurseForgeActivity.this, diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/ExternalModsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/ExternalModsActivity.java index e05230690..d39387039 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/ExternalModsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/ExternalModsActivity.java @@ -2,7 +2,7 @@ import android.content.ActivityNotFoundException; import android.content.Intent; -import android.graphics.Rect; +import android.content.res.ColorStateList; import android.net.Uri; import android.os.Bundle; import android.text.Editable; @@ -10,18 +10,16 @@ import android.text.format.Formatter; import android.text.TextWatcher; import android.view.View; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; +import android.widget.CompoundButton; import android.widget.EditText; -import android.widget.ImageButton; import android.widget.ProgressBar; -import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; -import androidx.annotation.NonNull; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.widget.CompoundButtonCompat; import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.button.MaterialButton; @@ -40,6 +38,8 @@ import org.levimc.launcher.ui.dialogs.InstallProgressDialog; import org.levimc.launcher.ui.views.MainViewModel; import org.levimc.launcher.ui.views.MainViewModelFactory; +import org.levimc.launcher.ui.widget.UiKit; +import org.levimc.launcher.util.PersonalizationManager; import java.util.ArrayList; import java.util.Comparator; @@ -55,10 +55,13 @@ public final class ExternalModsActivity extends BaseActivity { private TextView empty; private TextView status; private EditText search; - private MaterialButton compatibleFilter; - private Spinner sort; + private CompoundButton compatibleFilter; + private MaterialButton sort; private MaterialButton discord; - private ImageButton refresh; + private MaterialButton refresh; + private static final int SORT_NEWEST = 0; + private static final int SORT_NAME = 1; + private int sortMode = SORT_NEWEST; private ModCatalogInstaller installer; private InstallProgressDialog installDialog; private MainViewModel viewModel; @@ -97,6 +100,7 @@ protected void onCreate(Bundle savedInstanceState) { ModCatalogRepository.loadCached(this, (catalog, error) -> renderCatalog(catalog)); refreshCatalog(false); DynamicAnim.applyPressScaleRecursively(findViewById(android.R.id.content)); + new PersonalizationManager(this).applyAccentToView(findViewById(android.R.id.content), this); } @Override @@ -118,10 +122,7 @@ private void bindViews() { } private void setupRecycler() { - int spans = calculateSpanCount(); - recycler.setLayoutManager(new GridLayoutManager(this, spans)); - recycler.addItemDecoration(new GridSpacingDecoration( - spans, (int) (8 * getResources().getDisplayMetrics().density))); + recycler.setLayoutManager(new LinearLayoutManager(this)); adapter = new ExternalModsAdapter(minecraftVersion, this::handleModAction); recycler.setAdapter(adapter); } @@ -131,7 +132,11 @@ private void setupControls() { refresh.setOnClickListener(v -> refreshCatalog(true)); DynamicAnim.applyPressScale(discord); DynamicAnim.applyPressScale(refresh); - DynamicAnim.applyPressScale(compatibleFilter); + DynamicAnim.applyPressScale(sort); + // PersonalizationManager re-tints switches and buttons, not checkboxes. + int[][] states = {{android.R.attr.state_checked}, {}}; + CompoundButtonCompat.setButtonTintList(compatibleFilter, new ColorStateList(states, + new int[]{UiKit.accent(this), UiKit.secondaryText(this)})); } private void openCommunityDiscord() { @@ -145,28 +150,24 @@ private void openCommunityDiscord() { private void setupFilters() { compatibleFilter.setEnabled(!minecraftVersion.isEmpty()); compatibleOnly = !minecraftVersion.isEmpty(); - updateCompatibilityFilterText(); - compatibleFilter.setOnClickListener(v -> { - compatibleOnly = !compatibleOnly; - updateCompatibilityFilterText(); + compatibleFilter.setChecked(compatibleOnly); + compatibleFilter.setOnCheckedChangeListener((button, checked) -> { + compatibleOnly = checked; applyFilters(); }); - ArrayAdapter sortAdapter = new ArrayAdapter<>(this, R.layout.spinner_item, - new String[]{getString(R.string.external_mods_sort_newest), - getString(R.string.external_mods_sort_name)}); - sortAdapter.setDropDownViewResource(R.layout.spinner_dropdown_item); - sort.setAdapter(sortAdapter); - - sort.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { + updateSortText(); + sort.setOnClickListener(v -> { + PopupMenu menu = new PopupMenu(this, v); + menu.getMenu().add(0, SORT_NEWEST, 0, R.string.external_mods_sort_newest); + menu.getMenu().add(0, SORT_NAME, 1, R.string.external_mods_sort_name); + menu.setOnMenuItemClickListener(item -> { + sortMode = item.getItemId(); + updateSortText(); applyFilters(); - } - - @Override - public void onNothingSelected(AdapterView parent) { - } + return true; + }); + menu.show(); }); search.addTextChangedListener(new TextWatcher() { @Override @@ -184,10 +185,10 @@ public void afterTextChanged(Editable s) { }); } - private void updateCompatibilityFilterText() { - compatibleFilter.setText(compatibleOnly - ? R.string.external_mods_compatible_only - : R.string.external_mods_all_versions); + private void updateSortText() { + sort.setText(getString(R.string.mods_external_sort, getString(sortMode == SORT_NAME + ? R.string.external_mods_sort_name + : R.string.external_mods_sort_newest))); } private void refreshCatalog(boolean userRequested) { @@ -223,7 +224,7 @@ private void applyFilters() { filtered.add(mod); } - if (sort.getSelectedItemPosition() == 1) { + if (sortMode == SORT_NAME) { filtered.sort(Comparator.comparing(mod -> value(mod.name).toLowerCase(Locale.ROOT))); } else { filtered.sort(Comparator.comparing( @@ -390,35 +391,7 @@ private String publishedAt(ModCatalog.CatalogMod mod) { return release == null ? "" : value(release.publishedAt); } - private int calculateSpanCount() { - float width = getResources().getDisplayMetrics().widthPixels - / getResources().getDisplayMetrics().density; - if (width >= 480f) return 3; - if (width >= 320f) return 2; - return 1; - } - private String value(String value) { return value == null ? "" : value; } - - private static final class GridSpacingDecoration extends RecyclerView.ItemDecoration { - private final int spans; - private final int spacing; - - GridSpacingDecoration(int spans, int spacing) { - this.spans = spans; - this.spacing = spacing; - } - - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, - @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - int position = parent.getChildAdapterPosition(view); - int column = position % spans; - outRect.left = spacing - column * spacing / spans; - outRect.right = (column + 1) * spacing / spans; - if (position >= spans) outRect.top = spacing; - } - } } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/InstanceSettingsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/InstanceSettingsActivity.java index 652fa59f1..8bd93fe4b 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/InstanceSettingsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/InstanceSettingsActivity.java @@ -1,6 +1,9 @@ package org.levimc.launcher.ui.activities; import android.content.Intent; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -17,6 +20,7 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; +import com.google.android.material.button.MaterialButton; import com.google.android.material.switchmaterial.SwitchMaterial; import org.levimc.launcher.R; @@ -27,9 +31,14 @@ import org.levimc.launcher.ui.dialogs.InstallProgressDialog; import org.levimc.launcher.ui.packages.PackagesActivity; import org.levimc.launcher.ui.packages.LoaderCard; +import org.levimc.launcher.ui.widget.SegmentedControl; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.InstanceBackupManager; import org.levimc.launcher.util.InstanceShortcutManager; +import java.io.File; +import java.io.FileOutputStream; + public class InstanceSettingsActivity extends BaseActivity { private GameVersion version; @@ -39,7 +48,13 @@ public class InstanceSettingsActivity extends BaseActivity { private InstallProgressDialog backupProgressDialog; private Button backupButton; - private TextView tabGeneral, tabLaunchOptions, tabLoader, tabManagement; + private static final int TAB_GENERAL = 0; + private static final int TAB_LAUNCH_OPTIONS = 1; + private static final int TAB_LOADER = 2; + private static final int TAB_MANAGEMENT = 3; + private static final String ICON_DIR = "instance_icons"; + + private SegmentedControl tabs; private View sectionGeneral, sectionLaunchOptions, sectionLoader, sectionManagement; private EditText editName; @@ -47,6 +62,7 @@ public class InstanceSettingsActivity extends BaseActivity { private SwitchMaterial switchLaunchVertically; private EditText editShortcutName; private ImageView shortcutIconPreview; + private View shortcutIconPlaceholder; private String shortcutIconUri; private ActivityResultLauncher shortcutIconPicker; private final Handler autoSaveHandler = new Handler(Looper.getMainLooper()); @@ -62,11 +78,12 @@ protected void onCreate(Bundle savedInstanceState) { shortcutIconPicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { if (uri == null) return; - try { - getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } catch (Exception ignored) { + String stored = importIcon(uri); + if (stored == null) { + Toast.makeText(this, R.string.instances_ui_icon_import_failed, Toast.LENGTH_SHORT).show(); + return; } - shortcutIconUri = uri.toString(); + shortcutIconUri = stored; updateShortcutIconPreview(); saveShortcutSettings(); }); @@ -101,8 +118,10 @@ protected void onCreate(Bundle savedInstanceState) { loaderSection = new LoaderCard(this, version, findViewById(R.id.pkg_loader_card)); // The "Go to Loader" links open the Loader tab (PackagesActivity.Links.EXTRA_TAB). String tab = getIntent().getStringExtra(PackagesActivity.Links.EXTRA_TAB); - selectTab(PackagesActivity.Links.TAB_LOADER.equals(tab) ? tabLoader - : PackagesActivity.Links.TAB_MANAGEMENT.equals(tab) ? tabManagement : tabGeneral); + int initialTab = PackagesActivity.Links.TAB_LOADER.equals(tab) ? TAB_LOADER + : PackagesActivity.Links.TAB_MANAGEMENT.equals(tab) ? TAB_MANAGEMENT : TAB_GENERAL; + tabs.setSelectedIndex(initialTab, false); + selectTab(initialTab); } @Override @@ -114,10 +133,18 @@ protected void onResume() { } private void initViews() { - tabGeneral = findViewById(R.id.tab_general); - tabLaunchOptions = findViewById(R.id.tab_launch_options); - tabLoader = findViewById(R.id.tab_loader); - tabManagement = findViewById(R.id.tab_management); + tabs = findViewById(R.id.instance_tabs); + tabs.setItems(getString(R.string.instance_tab_general), + getString(R.string.instance_tab_launch_options), + getString(R.string.instance_tab_loader), + getString(R.string.instance_tab_management)); + tabs.setOnSegmentSelectedListener(this::selectTab); + + int accentText = UiKit.accentText(this); + ((TextView) findViewById(R.id.instance_auto_save_text)).setTextColor(accentText); + ((ImageView) findViewById(R.id.instance_auto_save_icon)) + .setImageTintList(ColorStateList.valueOf(accentText)); + UiKit.applyHeading(findViewById(R.id.instance_settings_title)); sectionGeneral = findViewById(R.id.section_general); sectionLaunchOptions = findViewById(R.id.section_launch_options); @@ -129,11 +156,8 @@ private void initViews() { switchLaunchVertically = findViewById(R.id.switch_launch_vertically); editShortcutName = findViewById(R.id.edit_shortcut_name); shortcutIconPreview = findViewById(R.id.shortcut_icon_preview); - - tabGeneral.setOnClickListener(v -> selectTab(tabGeneral)); - tabLaunchOptions.setOnClickListener(v -> selectTab(tabLaunchOptions)); - tabLoader.setOnClickListener(v -> selectTab(tabLoader)); - tabManagement.setOnClickListener(v -> selectTab(tabManagement)); + shortcutIconPlaceholder = findViewById(R.id.instance_icon_placeholder); + findViewById(R.id.btn_choose_shortcut_icon).setClipToOutline(true); switchIsolation.setOnCheckedChangeListener((buttonView, isChecked) -> { if (populatingData) return; @@ -178,11 +202,14 @@ public void afterTextChanged(Editable s) { findViewById(R.id.btn_choose_shortcut_icon).setOnClickListener(v -> shortcutIconPicker.launch(new String[]{"image/*"})); findViewById(R.id.btn_reset_shortcut_icon).setOnClickListener(v -> { + deleteStoredIcon(); shortcutIconUri = null; updateShortcutIconPreview(); saveShortcutSettings(); }); - findViewById(R.id.btn_add_instance_shortcut).setOnClickListener(v -> addInstanceShortcut()); + MaterialButton addShortcut = findViewById(R.id.btn_add_instance_shortcut); + UiKit.applyPrimary(addShortcut); + addShortcut.setOnClickListener(v -> addInstanceShortcut()); Button btnDelete = findViewById(R.id.btn_delete_instance); backupButton = findViewById(R.id.btn_backup_instance); @@ -199,10 +226,11 @@ public void afterTextChanged(Editable s) { private void populateData() { TextView instanceInfo = findViewById(R.id.instance_info); + TextView versionTag = findViewById(R.id.instance_version_tag); + versionTag.setText(version.versionCode != null ? version.versionCode : "—"); String type = version.isInstalled ? getString(R.string.tag_installed) : getString(R.string.tag_custom); - String info = "Game Version: " + (version.versionCode != null ? version.versionCode : "—") - + " · Name: " + (version.directoryName != null ? version.directoryName : "—") - + " · " + type; + String info = getString(R.string.instances_ui_info_name, + version.directoryName != null ? version.directoryName : "—") + " · " + type; instanceInfo.setText(info); String currentName = version.versionCode != null ? version.versionCode : ""; @@ -226,33 +254,10 @@ private void populateData() { populatingData = false; } - private void selectTab(TextView selectedTab) { - TextView[] tabs = {tabGeneral, tabLaunchOptions, tabLoader, tabManagement}; + private void selectTab(int index) { View[] sections = {sectionGeneral, sectionLaunchOptions, sectionLoader, sectionManagement}; - - org.levimc.launcher.util.PersonalizationManager pm = new org.levimc.launcher.util.PersonalizationManager(this); - int accent = pm.getAccentColor(); - - for (int i = 0; i < tabs.length; i++) { - boolean isSelected = tabs[i] == selectedTab; - - if (isSelected) { - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(16 * getResources().getDisplayMetrics().density); - tabs[i].setBackground(gd); - } else { - tabs[i].setBackgroundResource(R.drawable.bg_tab_selected); - } - tabs[i].setTextColor(android.graphics.Color.WHITE); - } else { - tabs[i].setBackgroundResource(R.drawable.bg_tab_unselected); - tabs[i].setTextColor(getColor(R.color.text_secondary)); - } - - if (isSelected) { + for (int i = 0; i < sections.length; i++) { + if (i == index) { sections[i].setVisibility(View.VISIBLE); sections[i].setAlpha(0f); sections[i].animate().alpha(1f).setDuration(200).start(); @@ -261,7 +266,7 @@ private void selectTab(TextView selectedTab) { } } if (loaderSection != null) { - loaderSection.setVisible(selectedTab == tabLoader); + loaderSection.setVisible(index == TAB_LOADER); } } @@ -310,11 +315,56 @@ private void addInstanceShortcut() { private void updateShortcutIconPreview() { if (shortcutIconPreview == null) return; - android.graphics.Bitmap bitmap = InstanceShortcutManager.loadIconBitmap(this, shortcutIconUri); + Bitmap bitmap = InstanceShortcutManager.loadIconBitmap(this, shortcutIconUri); if (bitmap != null) { shortcutIconPreview.setImageBitmap(bitmap); + shortcutIconPreview.setVisibility(View.VISIBLE); + if (shortcutIconPlaceholder != null) shortcutIconPlaceholder.setVisibility(View.GONE); } else { - shortcutIconPreview.setImageResource(R.mipmap.ic_launcher); + shortcutIconPreview.setImageDrawable(null); + shortcutIconPreview.setVisibility(View.GONE); + if (shortcutIconPlaceholder != null) shortcutIconPlaceholder.setVisibility(View.VISIBLE); + } + } + + /** + * Copies the picked image (cropped to a square) into app-private storage, so the icon keeps working + * after the picker's URI permission is gone. Returns the stored file URI, or null on failure. + */ + private String importIcon(Uri source) { + Bitmap bitmap = InstanceShortcutManager.loadIconBitmap(this, source.toString()); + if (bitmap == null) return null; + File dir = new File(getFilesDir(), ICON_DIR); + if (!dir.isDirectory() && !dir.mkdirs()) return null; + // A new name per pick, so a cached bitmap of the previous icon is never reused. + File target = new File(dir, InstanceShortcutManager.getShortcutId(version) + + "_" + System.currentTimeMillis() + ".png"); + try (FileOutputStream out = new FileOutputStream(target)) { + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) { + target.delete(); + return null; + } + } catch (Exception e) { + target.delete(); + return null; + } + deleteStoredIcon(); + return Uri.fromFile(target).toString(); + } + + /** Deletes the current icon file when it is one this screen stored in app-private storage. */ + private void deleteStoredIcon() { + if (shortcutIconUri == null) return; + try { + Uri uri = Uri.parse(shortcutIconUri); + if (!"file".equals(uri.getScheme()) || uri.getPath() == null) return; + File file = new File(uri.getPath()); + File parent = file.getParentFile(); + File dir = new File(getFilesDir(), ICON_DIR); + if (parent != null && dir.getCanonicalPath().equals(parent.getCanonicalPath())) { + file.delete(); + } + } catch (Exception ignored) { } } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/InstancesActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/InstancesActivity.java index 65e469615..ff7e6f647 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/InstancesActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/InstancesActivity.java @@ -29,6 +29,8 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.button.MaterialButton; + import org.levimc.launcher.R; import org.levimc.launcher.core.versions.GameVersion; import org.levimc.launcher.util.PersonalizationManager; @@ -37,12 +39,17 @@ import org.levimc.launcher.ui.dialogs.CustomAlertDialog; import org.levimc.launcher.ui.dialogs.InstallProgressDialog; import org.levimc.launcher.util.ApkImportManager; +import org.levimc.launcher.ui.widget.SegmentedControl; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.InstanceBackupManager; +import org.levimc.launcher.util.InstanceShortcutManager; import org.levimc.launcher.util.StorageAccess; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class InstancesActivity extends BaseActivity { public static final String EXTRA_RESTORE_BACKUP_ON_OPEN = "restore_backup_on_open"; @@ -60,10 +67,11 @@ public class InstancesActivity extends BaseActivity { private VersionManager versionManager; private RecyclerView recyclerView; private InstanceCardAdapter adapter; - private TextView filterAll, filterCustom; - private TextView instanceCountBadge; + private SegmentedControl filterControl; + private MaterialButton sortButton; private EditText searchInput; private int currentFilter = FILTER_ALL; + private boolean sortOldestFirst; private List allVersions = new ArrayList<>(); private ApkImportManager apkImportManager; @@ -131,6 +139,7 @@ protected void onCreate(Bundle savedInstanceState) { new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == RESULT_OK) { + if (adapter != null) adapter.clearIconCache(); versionManager.loadAllVersions(); loadVersions(); applyFilters(); @@ -139,9 +148,8 @@ protected void onCreate(Bundle savedInstanceState) { ); recyclerView = findViewById(R.id.instances_recycler); - filterAll = findViewById(R.id.filter_all); - filterCustom = findViewById(R.id.filter_custom); - instanceCountBadge = findViewById(R.id.instance_count_badge); + filterControl = findViewById(R.id.filter_segmented); + sortButton = findViewById(R.id.sort_button); searchInput = findViewById(R.id.search_input); int spanCount = calculateSpanCount(); @@ -175,10 +183,10 @@ protected void onCreate(Bundle savedInstanceState) { setupFilterTabs(); setupSearch(); + setupSortButton(); setupImportButton(); setupBackupImportButton(); setupBatchBackupButton(); - updateCount(); handleBackupOpenIntent(getIntent()); DynamicAnim.applyPressScaleRecursively(findViewById(android.R.id.content)); @@ -210,49 +218,33 @@ private void loadVersions() { Collections.sort(allVersions, (a, b) -> { String va = a.versionCode != null ? a.versionCode : ""; String vb = b.versionCode != null ? b.versionCode : ""; - return vb.compareTo(va); + return sortOldestFirst ? va.compareTo(vb) : vb.compareTo(va); }); } private void setupFilterTabs() { - updateFilterUI(); - - filterAll.setOnClickListener(v -> { - currentFilter = FILTER_ALL; - updateFilterUI(); + filterControl.setItems(getString(R.string.filter_all), getString(R.string.filter_custom)); + filterControl.setSelectedIndex(currentFilter, false); + filterControl.setOnSegmentSelectedListener(index -> { + currentFilter = index == 1 ? FILTER_CUSTOM : FILTER_ALL; applyFilters(); }); - filterCustom.setOnClickListener(v -> { - currentFilter = FILTER_CUSTOM; - updateFilterUI(); + } + + private void setupSortButton() { + updateSortLabel(); + sortButton.setOnClickListener(v -> { + sortOldestFirst = !sortOldestFirst; + updateSortLabel(); + loadVersions(); applyFilters(); }); } - private void updateFilterUI() { - PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - TextView[] tabs = {filterAll, filterCustom}; - for (int i = 0; i < tabs.length; i++) { - boolean selected = (i == currentFilter); - tabs[i].setSelected(selected); - if (selected) { - tabs[i].setTextColor(android.graphics.Color.WHITE); - tabs[i].setTypeface(tabs[i].getTypeface(), android.graphics.Typeface.BOLD); - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(20 * getResources().getDisplayMetrics().density); - tabs[i].setBackground(gd); - } - } else { - tabs[i].setTextColor(getResources().getColor(R.color.on_surface, getTheme())); - tabs[i].setTypeface(tabs[i].getTypeface(), android.graphics.Typeface.NORMAL); - tabs[i].setBackgroundResource(R.drawable.bg_filter_chip); - tabs[i].setSelected(false); - } - } + private void updateSortLabel() { + sortButton.setText(sortOldestFirst + ? R.string.instances_ui_sort_oldest + : R.string.sort_version_newest); } private void setupSearch() { @@ -271,54 +263,24 @@ public void afterTextChanged(Editable s) {} } private void setupImportButton() { - TextView btnImport = findViewById(R.id.btn_import_apk); + MaterialButton btnImport = findViewById(R.id.btn_import_apk); if (btnImport == null) return; - btnImport.setVisibility(View.VISIBLE); - btnImport.setSelected(true); - PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(20 * getResources().getDisplayMetrics().density); - btnImport.setBackground(gd); - btnImport.setTextColor(android.graphics.Color.WHITE); - } + UiKit.applyPrimary(btnImport); btnImport.setOnClickListener(v -> startApkFilePicker()); } private void setupBackupImportButton() { - TextView btnImportBackup = findViewById(R.id.btn_import_backup); + View btnImportBackup = findViewById(R.id.btn_import_backup); if (btnImportBackup == null) return; - btnImportBackup.setVisibility(View.VISIBLE); - btnImportBackup.setSelected(true); - applyAccentButtonStyle(btnImportBackup); btnImportBackup.setOnClickListener(v -> startBackupImportPicker()); } private void setupBatchBackupButton() { batchBackupButton = findViewById(R.id.btn_batch_backup); if (batchBackupButton == null) return; - batchBackupButton.setVisibility(View.VISIBLE); - batchBackupButton.setSelected(true); - applyAccentButtonStyle(batchBackupButton); batchBackupButton.setOnClickListener(v -> showBatchBackupSelection()); } - private void applyAccentButtonStyle(TextView button) { - PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(20 * getResources().getDisplayMetrics().density); - button.setBackground(gd); - button.setTextColor(android.graphics.Color.WHITE); - } - } - private void startApkFilePicker() { apkImportResultLauncher.launch(StorageAccess.downloadsPicker(false)); } @@ -786,13 +748,6 @@ private void applyFilters() { } adapter.updateData(filtered); - updateCount(); - } - - private void updateCount() { - if (instanceCountBadge != null && adapter != null) { - instanceCountBadge.setText(String.valueOf(adapter.getItemCount())); - } } private void setupNavBar() { @@ -806,6 +761,7 @@ protected void onResume() { if (firstResume) { firstResume = false; } else { + if (adapter != null) adapter.clearIconCache(); versionManager.loadAllVersions(); loadVersions(); } @@ -842,6 +798,7 @@ private static class InstanceCardAdapter extends RecyclerView.Adapter iconCache = new HashMap<>(); interface OnItemClickListener { void onClick(GameVersion version); @@ -883,74 +840,60 @@ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @Override public void onBindViewHolder(@NonNull VH holder, int position) { GameVersion v = versions.get(position); + Context ctx = holder.itemView.getContext(); boolean isSelected = selectedVersion != null && selectedVersion.directoryName != null && selectedVersion.directoryName.equals(v.directoryName); holder.itemView.setActivated(isSelected); - PersonalizationManager pm = new PersonalizationManager(holder.itemView.getContext()); - int accent = pm.getAccentColor(); + PersonalizationManager pm = new PersonalizationManager(ctx); boolean hasBackgroundImage = pm.hasBackgroundImage(); - boolean isDark = isDarkMode(holder.itemView.getContext()); - - int bgColor; + boolean isDark = isDarkMode(ctx); + float radius = UiKit.dp(ctx, 20); + int primaryTextColor; int secondaryTextColor; - int outlineColor; + if (isSelected) { + holder.itemView.setBackground(UiKit.selectedRing(ctx, 20)); + } else { + GradientDrawable gd = new GradientDrawable(); + gd.setShape(GradientDrawable.RECTANGLE); + gd.setCornerRadius(radius); + if (hasBackgroundImage) { + gd.setColor(isDark + ? Color.argb(CARD_GLASS_ALPHA_DARK, 18, 20, 24) + : Color.argb(CARD_GLASS_ALPHA_LIGHT, 255, 255, 255)); + gd.setStroke(Math.round(UiKit.dp(ctx, 1)), Color.argb( + isDark ? CARD_OUTLINE_ALPHA_DARK : CARD_OUTLINE_ALPHA_LIGHT, 255, 255, 255)); + } else { + gd.setColor(UiKit.color(ctx, R.color.surface)); + gd.setStroke(Math.round(UiKit.dp(ctx, 1)), UiKit.color(ctx, R.color.ui_card_stroke)); + } + holder.itemView.setBackground(gd); + } if (hasBackgroundImage) { - bgColor = isDark - ? Color.argb(CARD_GLASS_ALPHA_DARK, 18, 20, 24) - : Color.argb(CARD_GLASS_ALPHA_LIGHT, 255, 255, 255); primaryTextColor = isDark ? Color.WHITE : CARD_TEXT_PRIMARY_LIGHT; secondaryTextColor = isDark ? CARD_TEXT_SECONDARY_DARK : CARD_TEXT_SECONDARY_LIGHT; - outlineColor = Color.argb(isDark ? CARD_OUTLINE_ALPHA_DARK : CARD_OUTLINE_ALPHA_LIGHT, - 255, 255, 255); - } else { - bgColor = androidx.core.content.ContextCompat.getColor(holder.itemView.getContext(), R.color.surface); - primaryTextColor = androidx.core.content.ContextCompat.getColor(holder.itemView.getContext(), R.color.on_surface); - secondaryTextColor = androidx.core.content.ContextCompat.getColor(holder.itemView.getContext(), R.color.text_secondary); - outlineColor = androidx.core.content.ContextCompat.getColor(holder.itemView.getContext(), R.color.outline); - } - - GradientDrawable gd = new GradientDrawable(); - gd.setShape(GradientDrawable.RECTANGLE); - gd.setCornerRadius(12 * holder.itemView.getContext().getResources().getDisplayMetrics().density); - gd.setColor(bgColor); - - if (isSelected && accent != 0) { - gd.setStroke((int)(2 * holder.itemView.getContext().getResources().getDisplayMetrics().density), accent); } else { - gd.setStroke((int)(1 * holder.itemView.getContext().getResources().getDisplayMetrics().density), outlineColor); + primaryTextColor = UiKit.color(ctx, R.color.text_primary); + secondaryTextColor = UiKit.color(ctx, R.color.text_secondary); } - - holder.itemView.setBackground(gd); holder.versionCode.setText(v.versionCode != null ? v.versionCode : v.directoryName); holder.versionCode.setTextColor(primaryTextColor); holder.displayName.setTextColor(secondaryTextColor); - holder.settingsIcon.setImageTintList(ColorStateList.valueOf(secondaryTextColor)); if (v.isInstalled) { holder.typeTag.setText(R.string.tag_installed); - int tagColor = accent != 0 ? accent : holder.itemView.getContext().getResources().getColor(R.color.primary, holder.itemView.getContext().getTheme()); - holder.typeTag.setTextColor(tagColor); - if (hasBackgroundImage) { - holder.typeTag.setBackground(makeTagBackground(holder.itemView.getContext(), tagColor)); - } else { - holder.typeTag.setBackgroundResource(R.drawable.bg_release_tag); - } + UiKit.tintSoftAccent(holder.typeTag, 7); } else { holder.typeTag.setText(R.string.tag_custom); - int tagColor = hasBackgroundImage - ? secondaryTextColor - : holder.itemView.getContext().getResources().getColor(R.color.text_secondary, holder.itemView.getContext().getTheme()); - holder.typeTag.setTextColor(tagColor); - if (hasBackgroundImage) { - holder.typeTag.setBackground(makeTagBackground(holder.itemView.getContext(), tagColor)); - } else { - holder.typeTag.setBackgroundResource(R.drawable.bg_preview_tag); - } + GradientDrawable tagBg = new GradientDrawable(); + tagBg.setCornerRadius(UiKit.dp(ctx, 7)); + tagBg.setColor(UiKit.alpha(UiKit.INDIGO, isDark ? 46 : 28)); + holder.typeTag.setBackground(tagBg); + holder.typeTag.setTextColor(isDark ? 0xFFA5B4FC : 0xFF4F46E5); } holder.typeTag.setVisibility(View.VISIBLE); @@ -958,9 +901,10 @@ public void onBindViewHolder(@NonNull VH holder, int position) { if (v.displayName != null && !v.displayName.isEmpty()) { displayLabel = v.displayName; } else { - displayLabel = holder.itemView.getContext().getString(R.string.vanilla_prefix, v.versionCode != null ? v.versionCode : ""); + displayLabel = ctx.getString(R.string.vanilla_prefix, v.versionCode != null ? v.versionCode : ""); } holder.displayName.setText(displayLabel); + bindIcon(holder.icon, v); holder.settingsIcon.setOnClickListener(iv -> { if (settingsListener != null) settingsListener.onClick(v); @@ -973,20 +917,41 @@ public void onBindViewHolder(@NonNull VH holder, int position) { DynamicAnim.applyPressScale(holder.itemView); } + /** Shows the custom instance icon (set in instance settings) or the default cube. */ + private void bindIcon(ImageView icon, GameVersion v) { + Context ctx = icon.getContext(); + String uri = InstanceShortcutManager.getSavedIconUri(ctx, v); + android.graphics.Bitmap bitmap = null; + if (uri != null && !uri.isEmpty()) { + if (iconCache.containsKey(uri)) { + bitmap = iconCache.get(uri); + } else { + bitmap = InstanceShortcutManager.loadIconBitmap(ctx, uri); + if (bitmap != null) { + int size = Math.round(UiKit.dp(ctx, 28)); + bitmap = android.graphics.Bitmap.createScaledBitmap(bitmap, size, size, true); + } + iconCache.put(uri, bitmap); + } + } + icon.setClipToOutline(true); + if (bitmap != null) { + icon.setImageBitmap(bitmap); + } else { + icon.setImageResource(R.drawable.ic_minecraft_cube); + } + } + + void clearIconCache() { + iconCache.clear(); + } + private static boolean isDarkMode(Context context) { int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; return nightModeFlags == Configuration.UI_MODE_NIGHT_YES; } - private static GradientDrawable makeTagBackground(Context context, int color) { - GradientDrawable tagBg = new GradientDrawable(); - tagBg.setShape(GradientDrawable.RECTANGLE); - tagBg.setColor(Color.argb(28, Color.red(color), Color.green(color), Color.blue(color))); - tagBg.setCornerRadius(4 * context.getResources().getDisplayMetrics().density); - return tagBg; - } - @Override public int getItemCount() { return versions.size(); @@ -994,7 +959,8 @@ public int getItemCount() { static class VH extends RecyclerView.ViewHolder { TextView versionCode, typeTag, displayName; - ImageView settingsIcon; + ImageView icon; + MaterialButton settingsIcon; VH(View v) { super(v); @@ -1002,6 +968,7 @@ static class VH extends RecyclerView.ViewHolder { typeTag = v.findViewById(R.id.card_type_tag); displayName = v.findViewById(R.id.card_display_name); settingsIcon = v.findViewById(R.id.card_settings_icon); + icon = v.findViewById(R.id.card_icon); } } } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/MainActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/MainActivity.java index e439813d7..03a8cfd1f 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/MainActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/MainActivity.java @@ -42,6 +42,8 @@ import org.levimc.launcher.ui.dialogs.CustomAlertDialog; import org.levimc.launcher.ui.views.MainViewModel; +import org.levimc.launcher.ui.widget.UiKit; +import org.levimc.launcher.util.ThemePalette; import org.levimc.launcher.ui.views.MainViewModelFactory; import org.levimc.launcher.util.ApkImportManager; import org.levimc.launcher.util.GithubReleaseUpdater; @@ -102,12 +104,12 @@ public class MainActivity extends BaseActivity { private ContentManager contentManager; private TextView worldsCountText; private TextView resourcePacksCountText; - private TextView skinPacksCountText; + private TextView behaviorPacksCountText; private com.microsoft.xbox.idp.toolkit.CircleImageView accountAvatar; private View accountAvatarContainer; private ProgressBar avatarProgress; - private Button signInButton; + private View signInButton; private String lastAvatarXuid; private final OkHttpClient avatarClient = new OkHttpClient(); private ExecutorService accountExecutor = Executors.newSingleThreadExecutor(); @@ -499,7 +501,7 @@ private void initializeLauncher() { } private void initializeVersionManager() { - binding.launchButton.setEnabled(false); + setLaunchEnabled(false); versionManager = VersionManager.getIfInitialized(); if (versionManager != null) { onVersionManagerReady(); @@ -515,7 +517,7 @@ private void initializeVersionManager() { private void onVersionManagerReady() { if (versionManager == null || binding == null) return; fileHandler = new FileHandler(this, viewModel, versionManager); - binding.launchButton.setEnabled(true); + setLaunchEnabled(true); if (!handleInstanceShortcutLaunch()) { setTextMinecraftVersion(); updateViewModelVersion(); @@ -582,26 +584,11 @@ private void initModsSection() { if (viewModel == null) return; modsListContainer = binding.modsListContainer; + // The whole card opens the mods page; while mods are listed, the list itself is the touch target. binding.manageModsButton.setOnClickListener(v -> openModsFullscreen()); - DynamicAnim.applyPressScale(binding.manageModsButton); - - org.levimc.launcher.util.PersonalizationManager pm = new org.levimc.launcher.util.PersonalizationManager(this); - int accent = pm.getAccentColor(); - if (accent != 0) { - binding.manageModsButton.setTextColor(accent); - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - gd.setColor(android.graphics.Color.argb(26, android.graphics.Color.red(accent), android.graphics.Color.green(accent), android.graphics.Color.blue(accent))); - gd.setCornerRadius(5 * getResources().getDisplayMetrics().density); - gd.setStroke((int)(1 * getResources().getDisplayMetrics().density), - android.graphics.Color.argb(51, android.graphics.Color.red(accent), android.graphics.Color.green(accent), android.graphics.Color.blue(accent))); - binding.manageModsButton.setBackground(gd); - - if (binding.minecraftTitleText != null) { - pm.applySolidAccentText(binding.minecraftTitleText, accent); - } - binding.loaderChip.setTextColor(accent); - } + binding.modCard.setOnClickListener(v -> openModsFullscreen()); + modsListContainer.setOnClickListener(v -> openModsFullscreen()); + UiKit.tintSoftAccent(binding.loaderChip, 8); viewModel.getModsLiveData().observe(this, this::updateModsUI); // An instance with LeviLamina installed shows it by the title (it is not listed among the mods). @@ -693,17 +680,20 @@ protected void onResume() { refreshContentCounts(); } if (binding != null && versionManager != null) { - binding.launchButton.setEnabled(true); + setLaunchEnabled(true); } } @SuppressLint({"ClickableViewAccessibility", "UnsafeIntentLaunch"}) private void initListeners() { - binding.launchButton.setEnabled(false); + setLaunchEnabled(false); binding.launchButton.setOnClickListener(v -> launchGame()); - DynamicAnim.applyPressScale(binding.launchButton); + binding.homeLaunchMenuButton.setOnClickListener(this::showLaunchMenu); binding.selectVersionButton.setOnClickListener(v -> showVersionSelectDialog()); DynamicAnim.applyPressScale(binding.selectVersionButton); + binding.homeInstanceSettingsButton.setOnClickListener(v -> openInstanceSettings()); + DynamicAnim.applyPressScale(binding.homeInstanceSettingsButton); + styleHomeAccents(); FeatureSettings.init(getApplicationContext()); showRandomTip(); @@ -732,22 +722,21 @@ public void run() { private void initContentManagementSection() { worldsCountText = binding.contentWorldsCount; resourcePacksCountText = binding.contentResourcePacksCount; - skinPacksCountText = binding.contentSkinPacksCount; + behaviorPacksCountText = binding.contentBehaviorPacksCount; contentManager = ContentManager.getInstance(this); contentManager.getCountsLiveData().observe(this, counts -> { if (counts == null) return; worldsCountText.setText(String.valueOf(counts.worlds)); resourcePacksCountText.setText(String.valueOf(counts.resourcePacks)); - skinPacksCountText.setText(String.valueOf(counts.skinPacks)); + behaviorPacksCountText.setText(String.valueOf(counts.behaviorPacks)); }); binding.contentViewAll.setOnClickListener(v -> openContentManagement()); - DynamicAnim.applyPressScale(binding.contentViewAll); binding.contentWorldsRow.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_WORLDS)); binding.contentResourcePacksRow.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_RESOURCE_PACKS)); - binding.contentSkinPacksRow.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_SKIN_PACKS)); + binding.contentBehaviorPacksRow.setOnClickListener(v -> openContentList(ContentListActivity.TYPE_BEHAVIOR_PACKS)); refreshContentCounts(); } @@ -802,8 +791,66 @@ private void initMiscellaneousSection() { binding.miscPkgRow.setOnClickListener(v -> startActivity(org.levimc.launcher.ui.packages.PackagesActivity.intent( this, versionManager != null ? versionManager.getSelectedVersion() : null, null))); binding.miscExternalModsRow.setOnClickListener(v -> startActivity(new Intent(this, ExternalModsActivity.class))); - binding.miscAccountsRow.setOnClickListener(v -> startActivity(new Intent(this, AccountsActivity.class))); - binding.miscQuickLaunchRow.setOnClickListener(v -> startActivity(new Intent(this, QuickLaunchActivity.class))); + } + + /** Colors that depend on the chosen theme color, and the per-category icon chips. */ + private void styleHomeAccents() { + int accent = UiKit.accent(this); + int onAccent = ThemePalette.onAccent(accent); + GradientDrawable launchFill = new GradientDrawable(); + launchFill.setCornerRadius(UiKit.dp(this, 14)); + launchFill.setColor(accent); + binding.homeLaunchGroup.setBackground(launchFill); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + binding.homeLaunchGroup.setOutlineSpotShadowColor(accent); + binding.homeLaunchGroup.setOutlineAmbientShadowColor(accent); + } + binding.launchButton.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)); + binding.launchButton.setTextColor(onAccent); + binding.launchButton.setIconTint(ColorStateList.valueOf(onAccent)); + binding.homeLaunchMenuButton.setImageTintList(ColorStateList.valueOf(onAccent)); + binding.homeLaunchDivider.setBackgroundColor(onAccent); + + UiKit.tintIconChip(binding.homeModsChip, binding.homeModsIcon, UiKit.INDIGO); + UiKit.tintIconChip(binding.homeContentChip, binding.homeContentIcon, UiKit.PINK); + UiKit.tintIconChip(binding.homeDownloadChip, binding.homeDownloadIcon, UiKit.ORANGE); + UiKit.tintIconChip(binding.homeWorldsChip, binding.homeWorldsIcon, UiKit.BLUE); + UiKit.tintIconChip(binding.homeResourcePacksChip, binding.homeResourcePacksIcon, UiKit.PURPLE); + UiKit.tintIconChip(binding.homeBehaviorPacksChip, binding.homeBehaviorPacksIcon, UiKit.ORANGE); + // The CurseForge logo keeps its own colors; only its chip is tinted. + UiKit.tintIconChip(binding.homeCurseforgeChip, null, UiKit.ORANGE); + UiKit.tintIconChip(binding.homePkgChip, binding.homePkgIcon, UiKit.GREEN); + UiKit.tintIconChip(binding.homeExternalModsChip, binding.homeExternalModsIcon, UiKit.INDIGO); + } + + private void setLaunchEnabled(boolean enabled) { + if (binding == null) return; + binding.launchButton.setEnabled(enabled); + binding.homeLaunchMenuButton.setEnabled(enabled); + binding.homeLaunchGroup.setAlpha(enabled ? 1f : 0.6f); + } + + private void showLaunchMenu(View anchor) { + android.widget.PopupMenu menu = new android.widget.PopupMenu(this, anchor, Gravity.END); + menu.getMenu().add(0, 1, 0, R.string.quick_launch); + menu.setOnMenuItemClickListener(item -> { + if (item.getItemId() != 1) return false; + startActivity(new Intent(this, QuickLaunchActivity.class)); + return true; + }); + menu.show(); + } + + private void openInstanceSettings() { + if (!isVersionManagerReady()) return; + GameVersion currentVersion = versionManager.getSelectedVersion(); + if (currentVersion == null) { + Toast.makeText(this, getString(R.string.not_found_version), Toast.LENGTH_SHORT).show(); + return; + } + Intent intent = new Intent(this, InstanceSettingsActivity.class); + intent.putExtra("version", currentVersion); + startActivity(intent); } private void openModsFullscreen() { @@ -816,7 +863,7 @@ private void launchGame() { performActualLaunch(); } private void performActualLaunch() { - binding.launchButton.setEnabled(false); + setLaunchEnabled(false); LaunchTrace trace = LaunchTrace.create(null); trace.milestone("Launch requested"); @@ -824,7 +871,7 @@ private void performActualLaunch() { if (version == null) { trace.warning("Launch cancelled", "No version selected"); - binding.launchButton.setEnabled(true); + setLaunchEnabled(true); new CustomAlertDialog(this) .setTitleText(getString(R.string.dialog_title_no_version)) .setMessage(getString(R.string.dialog_message_no_version)) @@ -839,7 +886,7 @@ private void performActualLaunch() { boolean loggedIn = active != null && active.minecraftUsername != null && !active.minecraftUsername.isEmpty(); if (!loggedIn) { trace.warning("Launch cancelled", "Minecraft account is missing"); - binding.launchButton.setEnabled(true); + setLaunchEnabled(true); new CustomAlertDialog(this) .setTitleText(getString(R.string.dialog_title_login_required)) .setMessage(getString(R.string.dialog_message_login_required)) @@ -868,13 +915,13 @@ public void onLaunchStarted() { public void onLaunchFailed(Exception e) { trace.error("Launch failed before loading screen", e.getMessage()); runOnUiThread(() -> { - if (binding != null) binding.launchButton.setEnabled(true); + setLaunchEnabled(true); }); } }); } catch (Exception e) { trace.error("Launch failed before activity start", e.getMessage()); - binding.launchButton.setEnabled(true); + setLaunchEnabled(true); new CustomAlertDialog(this) .setTitleText(getString(R.string.dialog_title_launch_failed)) .setMessage(getString(R.string.dialog_message_launch_failed, e.getMessage())) @@ -1091,6 +1138,13 @@ public void setTextMinecraftVersion() { GameVersion selectedVersion = versionManager.getSelectedVersion(); String instanceName = selectedVersion != null ? getInstanceDisplayName(selectedVersion) : null; binding.textMinecraftVersion.setText(TextUtils.isEmpty(instanceName) ? getString(R.string.not_found_version) : instanceName); + binding.homeInstanceLetter.setText(instanceLetter(instanceName)); + } + + private static String instanceLetter(String name) { + String trimmed = name != null ? name.trim() : ""; + if (trimmed.isEmpty()) return "M"; + return new String(Character.toChars(trimmed.codePointAt(0))).toUpperCase(java.util.Locale.ROOT); } private boolean isVersionManagerReady() { @@ -1192,25 +1246,32 @@ private void updateModsUI(List mods) { if (binding == null || modsListContainer == null) return; modsListContainer.removeAllViews(); - // Add enabled external mods + int shown = 0; if (mods != null) { for (Mod mod : mods) { if (mod.isEnabled()) { addModNameEntry(mod.getDisplayName()); + shown++; } } } - + binding.homeModsScroll.setVisibility(shown > 0 ? View.VISIBLE : View.GONE); + binding.homeModsEmpty.setVisibility(shown > 0 ? View.GONE : View.VISIBLE); + binding.homeModsEmptyText.setText(mods == null || mods.isEmpty() + ? R.string.home_mods_empty : R.string.home_mods_none_enabled); } private void addModNameEntry(String name) { TextView tv = new TextView(this); tv.setText(name); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); - tv.setTextColor(androidx.core.content.ContextCompat.getColor(this, R.color.on_surface)); - tv.setFontFeatureSettings(null); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + tv.setTextColor(ContextCompat.getColor(this, R.color.text_primary)); tv.setTypeface(getResources().getFont(R.font.misans)); - tv.setPadding(0, (int)(3 * getResources().getDisplayMetrics().density), 0, (int)(3 * getResources().getDisplayMetrics().density)); + tv.setGravity(Gravity.CENTER_VERTICAL); + tv.setMinHeight((int) UiKit.dp(this, 36)); + tv.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_ui_puzzle, 0, 0, 0); + tv.setCompoundDrawableTintList(ColorStateList.valueOf(ContextCompat.getColor(this, R.color.ui_muted))); + tv.setCompoundDrawablePadding((int) UiKit.dp(this, 10)); tv.setMaxLines(1); tv.setEllipsize(android.text.TextUtils.TruncateAt.END); modsListContainer.addView(tv); diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/ModsFullscreenActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/ModsFullscreenActivity.java index 2799370dc..863403c23 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/ModsFullscreenActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/ModsFullscreenActivity.java @@ -1,13 +1,18 @@ package org.levimc.launcher.ui.activities; import android.content.Intent; +import android.content.res.ColorStateList; import android.net.Uri; import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; import android.view.View; -import android.widget.Button; - +import android.widget.CompoundButton; +import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.widget.CompoundButtonCompat; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; @@ -35,18 +40,40 @@ import org.levimc.launcher.ui.views.MainViewModel; import org.levimc.launcher.ui.views.MainViewModelFactory; import org.levimc.launcher.util.StorageAccess; +import org.levimc.launcher.ui.widget.SegmentedControl; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.PersonalizationManager; import java.lang.ref.WeakReference; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.Locale; +import java.util.Map; public class ModsFullscreenActivity extends BaseActivity { private RecyclerView modsRecycler; private ModsAdapter modsAdapter; private MainViewModel viewModel; - private TextView totalModsCount; - private TextView enabledModsCount; + private TextView subtitle; + private EditText searchInput; + private CompoundButton onlyEnabledCheck; + private CompoundButton modMenuSwitch; + private SegmentedControl filterTabs; + private TextView sortNameHeader; + private View emptyView; + private TextView emptyText; + /** Every mod of the instance in load order; the adapter shows the filtered / sorted subset. */ + private final List allMods = new ArrayList<>(); + private Map pkgRowsByFolder = Collections.emptyMap(); + private static final int TAB_ALL = 0; + private static final int TAB_NORMAL = 1; + private static final int TAB_LEVIMC = 2; + private static final int SORT_NONE = 0; + private static final int SORT_ASC = 1; + private static final int SORT_DESC = 2; + private int sortMode = SORT_NONE; private ActivityResultLauncher pickModLauncher; private FileHandler fileHandler; private InbuiltModManager inbuiltModManager; @@ -70,6 +97,7 @@ protected void onCreate(Bundle savedInstanceState) { setupViewModel(); setupRecyclerView(); fileHandler = new FileHandler(this, viewModel, VersionManager.get(this)); + // The first applyFilters() runs when the mod list arrives, so the empty state is not shown while loading. pickModLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), @@ -86,44 +114,146 @@ protected void onCreate(Bundle savedInstanceState) { } private void setupViews() { - Button addModButton = findViewById(R.id.add_mod_fullscreen_button); - addModButton.setVisibility(View.VISIBLE); - addModButton.setOnClickListener(v -> { - startFilePicker(); - }); - DynamicAnim.applyPressScale(addModButton); - - Button modMenuButton = findViewById(R.id.mod_menu_button); - boolean isMenuEnabled = inbuiltModManager.isModMenuEnabled(); - modMenuButton.setText(getString(R.string.mod_menu) + ": " + (isMenuEnabled ? "ON" : "OFF")); - modMenuButton.setOnClickListener(v -> { - boolean current = inbuiltModManager.isModMenuEnabled(); - inbuiltModManager.setModMenuEnabled(!current); - modMenuButton.setText(getString(R.string.mod_menu) + ": " + (!current ? "ON" : "OFF")); - Toast.makeText(this, !current ? R.string.mod_menu_enabled : R.string.mod_menu_disabled, Toast.LENGTH_SHORT).show(); + View addModButton = findViewById(R.id.add_mod_fullscreen_button); + addModButton.setOnClickListener(v -> startFilePicker()); + + View getModsButton = findViewById(R.id.mods_get_button); + getModsButton.setOnClickListener(this::showGetModsMenu); + + modMenuSwitch = findViewById(R.id.mod_menu_switch); + syncModMenuSwitch(); + + subtitle = findViewById(R.id.mods_subtitle); + emptyView = findViewById(R.id.mods_empty); + emptyText = findViewById(R.id.mods_empty_text); + + searchInput = findViewById(R.id.mods_search); + searchInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + applyFilters(); + } }); - DynamicAnim.applyPressScale(modMenuButton); - Button externalModsButton = findViewById(R.id.external_mods_button); - externalModsButton.setOnClickListener(v -> startActivity( - new Intent(this, ExternalModsActivity.class), - ActivityOptionsCompat.makeCustomAnimation(this, R.anim.fade_in, R.anim.fade_out).toBundle())); - DynamicAnim.applyPressScale(externalModsButton); + onlyEnabledCheck = findViewById(R.id.mods_only_enabled); + onlyEnabledCheck.setOnCheckedChangeListener((b, checked) -> applyFilters()); - Button pkgPackagesButton = findViewById(R.id.pkg_packages_button); - pkgPackagesButton.setOnClickListener(v -> startActivity( - org.levimc.launcher.ui.packages.PackagesActivity.intent(this, VersionManager.get(this).getSelectedVersion(), null), - ActivityOptionsCompat.makeCustomAnimation(this, R.anim.fade_in, R.anim.fade_out).toBundle())); - DynamicAnim.applyPressScale(pkgPackagesButton); + filterTabs = findViewById(R.id.mods_filter_tabs); + filterTabs.setItems(getString(R.string.mods_filter_all), getString(R.string.mods_filter_normal), + getString(R.string.pkg_title_mods)); + filterTabs.setSelectedIndex(TAB_ALL, false); + filterTabs.setOnSegmentSelectedListener(i -> applyFilters()); - totalModsCount = findViewById(R.id.total_mods_count); - enabledModsCount = findViewById(R.id.enabled_mods_count); + sortNameHeader = findViewById(R.id.mods_sort_name); + sortNameHeader.setOnClickListener(v -> { + sortMode = (sortMode + 1) % 3; + updateSortHeader(); + applyFilters(); + }); + updateSortHeader(); + + findViewById(R.id.mods_refresh_button).setOnClickListener(v -> { + if (viewModel != null) { + viewModel.reloadMods(); + } + }); + + // PersonalizationManager re-tints switches and buttons, not checkboxes. + int[][] states = {{android.R.attr.state_checked}, {}}; + CompoundButtonCompat.setButtonTintList(onlyEnabledCheck, new ColorStateList(states, + new int[]{UiKit.accent(this), UiKit.secondaryText(this)})); - PersonalizationManager personalizationManager = new PersonalizationManager(this); View root = findViewById(android.R.id.content); if (root != null) { - personalizationManager.applyAccentToView(root, this); + new PersonalizationManager(this).applyAccentToView(root, this); + } + } + + private void syncModMenuSwitch() { + modMenuSwitch.setOnCheckedChangeListener(null); + modMenuSwitch.setChecked(inbuiltModManager.isModMenuEnabled()); + modMenuSwitch.setOnCheckedChangeListener((b, checked) -> { + if (checked == inbuiltModManager.isModMenuEnabled()) return; + inbuiltModManager.setModMenuEnabled(checked); + Toast.makeText(this, checked ? R.string.mod_menu_enabled : R.string.mod_menu_disabled, + Toast.LENGTH_SHORT).show(); + }); + } + + /** "Get mods": LeviMC mods (package browser) or the external mod catalog. */ + private void showGetModsMenu(View anchor) { + PopupMenu menu = new PopupMenu(this, anchor); + menu.getMenu().add(0, 1, 0, R.string.pkg_title_mods); + menu.getMenu().add(0, 2, 1, R.string.external_mods); + menu.setOnMenuItemClickListener(item -> { + Intent intent = item.getItemId() == 1 + ? org.levimc.launcher.ui.packages.PackagesActivity.intent(this, VersionManager.get(this).getSelectedVersion(), null) + : new Intent(this, ExternalModsActivity.class); + startActivity(intent, ActivityOptionsCompat.makeCustomAnimation(this, R.anim.fade_in, R.anim.fade_out).toBundle()); + return true; + }); + menu.show(); + } + + private void updateSortHeader() { + String label = getString(R.string.mods_col_name); + if (sortMode == SORT_ASC) label += " ▲"; + else if (sortMode == SORT_DESC) label += " ▼"; + sortNameHeader.setText(label); + } + + /** A mod folder a LeviMC (lip) package owns. */ + private boolean isLeviMcMod(Mod mod) { + return pkgRowsByFolder.containsKey(InstalledFragment.Model.folderKey(mod.getId())); + } + + /** Shows the mods matching the search, the "only enabled" box and the source tab, in the chosen order. */ + private void applyFilters() { + if (modsAdapter == null || filterTabs == null) return; + String query = searchInput.getText().toString().trim().toLowerCase(Locale.ROOT); + boolean onlyEnabled = onlyEnabledCheck.isChecked(); + int tab = filterTabs.getSelectedIndex(); + + List shown = new ArrayList<>(); + for (Mod mod : allMods) { + if (onlyEnabled && !mod.isEnabled()) continue; + if (tab == TAB_NORMAL && isLeviMcMod(mod)) continue; + if (tab == TAB_LEVIMC && !isLeviMcMod(mod)) continue; + if (!query.isEmpty() && !matches(mod, query)) continue; + shown.add(mod); + } + if (sortMode != SORT_NONE) { + Comparator byName = Comparator.comparing(m -> lower(m.getDisplayName())); + shown.sort(sortMode == SORT_ASC ? byName : byName.reversed()); } + + // Drag-to-reorder edits the load order, so it only works on the full, unsorted list. + boolean filtered = !query.isEmpty() || onlyEnabled || tab != TAB_ALL || sortMode != SORT_NONE; + modsAdapter.setReorderEnabled(!filtered); + modsAdapter.updateMods(shown); + + boolean empty = shown.isEmpty(); + modsRecycler.setVisibility(empty ? View.GONE : View.VISIBLE); + emptyView.setVisibility(empty ? View.VISIBLE : View.GONE); + emptyText.setText(allMods.isEmpty() ? R.string.mods_empty : R.string.mods_empty_filtered); + } + + private static boolean matches(Mod mod, String query) { + return lower(mod.getDisplayName()).contains(query) + || lower(mod.getId()).contains(query) + || lower(mod.getAuthor()).contains(query); + } + + private static String lower(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); } private void startFilePicker() { @@ -203,6 +333,11 @@ private void setupRecyclerView() { ItemTouchHelper.SimpleCallback simpleCallback = new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.LEFT) { private long lastScrollTime = 0; + @Override + public int getDragDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + return modsAdapter.isReorderEnabled() ? super.getDragDirs(recyclerView, viewHolder) : 0; + } + @Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { int fromPosition = viewHolder.getAdapterPosition(); @@ -214,7 +349,9 @@ public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView. @Override public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); - modsAdapter.commitReorder(); + if (modsAdapter.isReorderEnabled()) { + modsAdapter.commitReorder(); + } } @Override @@ -310,7 +447,11 @@ private void loadPkgRows(List mods) { int request = ++pkgRequest; InstalledFragment.Loader.loadForMods(pkgCancel, currentVersion(), mods, rows -> { if (request == pkgRequest && modsAdapter != null) { - modsAdapter.setPkgRows(InstalledFragment.Model.byFolder(rows)); + pkgRowsByFolder = InstalledFragment.Model.byFolder(rows); + modsAdapter.setPkgRows(pkgRowsByFolder); + if (filterTabs != null && filterTabs.getSelectedIndex() != TAB_ALL) { + applyFilters(); + } } }); } @@ -323,7 +464,9 @@ protected void onDestroy() { private void updateModsUI(List mods) { if (modsAdapter != null) { - modsAdapter.updateMods(mods); + allMods.clear(); + if (mods != null) allMods.addAll(mods); + applyFilters(); loadPkgRows(mods); updateModsCount(); if (modsRecycler != null) { @@ -350,14 +493,19 @@ private void updateModsCount() { } } - totalModsCount.setText(String.valueOf(total)); - enabledModsCount.setText(String.valueOf(enabled)); + GameVersion version = currentVersion(); + String instance = version == null ? null + : (version.displayName != null && !version.displayName.trim().isEmpty() + ? version.displayName : version.directoryName); + subtitle.setText(instance == null + ? getString(R.string.mods_subtitle_counts, total, enabled) + : getString(R.string.mods_subtitle, instance, total, enabled)); } @Override protected void onResume() { super.onResume(); - setupViews(); + syncModMenuSwitch(); if (viewModel != null) { viewModel.refreshMods(); } diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/NavRoutes.java b/app/src/main/java/org/levimc/launcher/ui/activities/NavRoutes.java new file mode 100644 index 000000000..735352dd5 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/ui/activities/NavRoutes.java @@ -0,0 +1,77 @@ +package org.levimc.launcher.ui.activities; + +import android.app.Activity; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.levimc.launcher.R; + +import java.util.HashMap; +import java.util.Map; + +/** + * Page hierarchy for the breadcrumbs in the top bar (Windows: 首页 › 内容管理 › 世界). + * Each page names its parent; {@code standalone} pages can be opened without extras, so their + * crumbs are tappable even when they are not in the back stack. + */ +final class NavRoutes { + private NavRoutes() { + } + + static final class Route { + @StringRes + final int title; + @Nullable + final Class parent; + final boolean standalone; + + Route(int title, @Nullable Class parent, boolean standalone) { + this.title = title; + this.parent = parent; + this.standalone = standalone; + } + } + + private static final Map, Route> ROUTES = new HashMap<>(); + + static { + put(MainActivity.class, R.string.crumb_home, null, true); + put(InstancesActivity.class, R.string.crumb_instances, MainActivity.class, true); + put(InstanceSettingsActivity.class, R.string.crumb_instance_settings, InstancesActivity.class, false); + put(ContentManagementActivity.class, R.string.crumb_content, MainActivity.class, true); + put(ContentListActivity.class, R.string.crumb_content, ContentManagementActivity.class, false); + put(OptionsEditorActivity.class, R.string.crumb_options_editor, ContentManagementActivity.class, false); + put(CustomFlatWorldActivity.class, R.string.crumb_custom_flat_world, ContentListActivity.class, false); + put(WorldEditorActivity.class, R.string.crumb_details, ContentListActivity.class, false); + put(ModsFullscreenActivity.class, R.string.crumb_mods, MainActivity.class, true); + put(ModDetailActivity.class, R.string.crumb_details, ModsFullscreenActivity.class, false); + put(ModConfigActivity.class, R.string.crumb_mod_config, ModDetailActivity.class, false); + put(ExternalModsActivity.class, R.string.crumb_external_mods, MainActivity.class, true); + put(CurseForgeActivity.class, R.string.crumb_curseforge, MainActivity.class, true); + put(ContentDetailsActivity.class, R.string.crumb_details, CurseForgeActivity.class, false); + put(org.levimc.launcher.ui.packages.PackagesActivity.class, R.string.crumb_levimc_mods, MainActivity.class, true); + put(org.levimc.launcher.ui.packages.PackageActivity.class, R.string.crumb_details, + org.levimc.launcher.ui.packages.PackagesActivity.class, false); + put(SettingsActivity.class, R.string.crumb_settings, MainActivity.class, true); + put(AboutActivity.class, R.string.crumb_about, MainActivity.class, true); + put(AccountsActivity.class, R.string.crumb_accounts, MainActivity.class, true); + put(NewsActivity.class, R.string.crumb_news, MainActivity.class, true); + put(QuickLaunchActivity.class, R.string.crumb_quick_launch, MainActivity.class, true); + } + + private static void put(Class page, int title, @Nullable Class parent, boolean standalone) { + ROUTES.put(page, new Route(title, parent, standalone)); + } + + @Nullable + static Route get(Class page) { + Class c = page; + while (c != null && c != BaseActivity.class) { + Route r = ROUTES.get(c); + if (r != null) return r; + c = c.getSuperclass(); + } + return null; + } +} diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/NewsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/NewsActivity.java index 54a6b01f1..34a6fa023 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/NewsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/NewsActivity.java @@ -7,9 +7,7 @@ import android.os.Bundle; import android.view.View; import android.widget.Button; -import android.widget.ImageButton; import android.widget.ProgressBar; -import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; @@ -31,9 +29,9 @@ public final class NewsActivity extends BaseActivity { private RecyclerView recycler; private NewsAdapter adapter; private ProgressBar progress; - private TextView empty; + private View empty; private Button enableNotifications; - private ImageButton refresh; + private View refresh; private ActivityResultLauncher notificationPermissionLauncher; private boolean rendered; diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/NewsAdapter.java b/app/src/main/java/org/levimc/launcher/ui/activities/NewsAdapter.java index 57184dad0..b0cd6f1f7 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/NewsAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/NewsAdapter.java @@ -1,6 +1,7 @@ package org.levimc.launcher.ui.activities; import android.content.Intent; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.TextUtils; import android.view.LayoutInflater; @@ -9,6 +10,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.card.MaterialCardView; @@ -16,6 +18,7 @@ import org.levimc.launcher.R; import org.levimc.launcher.core.news.NewsItem; import org.levimc.launcher.ui.animation.DynamicAnim; +import org.levimc.launcher.ui.widget.UiKit; import java.time.Instant; import java.time.ZoneId; @@ -65,8 +68,7 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { holder.unread.setVisibility(unreadIds.contains(item.id) ? View.VISIBLE : View.GONE); holder.important.setVisibility(item.important ? View.VISIBLE : View.GONE); holder.action.setVisibility(TextUtils.isEmpty(item.url) ? View.GONE : View.VISIBLE); - holder.card.setStrokeWidth(item.important ? dp(holder.itemView, 1) : 0); - holder.card.setStrokeColor(holder.itemView.getContext().getColor(R.color.primary)); + styleReadMore(holder.action); View.OnClickListener open = v -> { if (TextUtils.isEmpty(item.url)) return; @@ -98,6 +100,19 @@ private static int dp(View view, int value) { return (int) (value * view.getResources().getDisplayMetrics().density); } + private static void styleReadMore(TextView action) { + int color = UiKit.accentText(action.getContext()); + action.setTextColor(color); + Drawable icon = ContextCompat.getDrawable(action.getContext(), R.drawable.ic_ui_open_in_new); + if (icon != null) { + icon = icon.mutate(); + int size = dp(action, 16); + icon.setBounds(0, 0, size, size); + icon.setTint(color); + } + action.setCompoundDrawablesRelative(null, null, icon, null); + } + static final class Holder extends RecyclerView.ViewHolder { final MaterialCardView card; final TextView title; diff --git a/app/src/main/java/org/levimc/launcher/ui/activities/SettingsActivity.java b/app/src/main/java/org/levimc/launcher/ui/activities/SettingsActivity.java index b77088e6a..ab4127bd1 100644 --- a/app/src/main/java/org/levimc/launcher/ui/activities/SettingsActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/activities/SettingsActivity.java @@ -1,94 +1,154 @@ package org.levimc.launcher.ui.activities; +import android.app.TimePickerDialog; +import android.content.ActivityNotFoundException; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.ColorStateList; +import android.graphics.Bitmap; import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.DocumentsContract; import android.util.Base64; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Button; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; import android.widget.FrameLayout; -import android.widget.GridLayout; import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.PopupMenu; import android.widget.ScrollView; import android.widget.SeekBar; -import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; -import com.google.android.material.switchmaterial.SwitchMaterial; - import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.switchmaterial.SwitchMaterial; + import org.levimc.launcher.R; import org.levimc.launcher.core.crash.CrashReporter; import org.levimc.launcher.core.discord.DiscordPresence; import org.levimc.launcher.preloader.PreloaderSignatureRulesManager; import org.levimc.launcher.settings.FeatureSettings; import org.levimc.launcher.ui.animation.DynamicAnim; +import org.levimc.launcher.ui.dialogs.CustomAlertDialog; import org.levimc.launcher.ui.dialogs.LogcatOverlayManager; +import org.levimc.launcher.ui.dialogs.SimpleTextWatcher; import org.levimc.launcher.ui.packages.SettingsCard; +import org.levimc.launcher.ui.widget.SegmentedControl; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.GithubReleaseUpdater; import org.levimc.launcher.util.LanguageManager; +import org.levimc.launcher.util.LauncherBackgroundController; import org.levimc.launcher.util.PermissionsHandler; import org.levimc.launcher.util.PersonalizationManager; -import org.levimc.launcher.util.LauncherBackgroundController; import org.levimc.launcher.util.ThemeManager; +import org.levimc.launcher.util.ThemePalette; +import org.levimc.launcher.util.UiSettings; +import org.levimc.launcher.util.WallpaperFolder; -import java.text.DateFormat; import java.lang.ref.WeakReference; +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Calendar; import java.util.Date; +import java.util.List; import java.util.Locale; public class SettingsActivity extends BaseActivity { + /** Optional search text: the page opens on the first setting that matches it. */ + public static final String EXTRA_SEARCH = "org.levimc.launcher.settings.SEARCH"; + + private static final String PAGE_PREFS = "settings_page_prefs"; + private static final String KEY_SELECTED_TAB = "selected_tab_index"; + private static final String KEY_PERSONALIZE_SECTION = "personalize_section_index"; + private static final String KEY_WAS_DARK = "settings_was_dark"; + private static final String KEY_EDIT_DARK = "edit_dark"; + private static final String KEY_MATERIAL_DARK = "material_dark"; + private static final String ROW_TAG = "settings_row"; + + private static final int TAB_PERSONALIZE = 1; + private static final int SECTION_WALLPAPER = 2; + + // Order of the Windows "外观模式" tabs. + private static final int[] THEME_MODES = {ThemeManager.MODE_LIGHT, ThemeManager.MODE_DARK, + ThemeManager.MODE_SCHEDULE, ThemeManager.MODE_SUN, ThemeManager.MODE_FOLLOW_SYSTEM}; + private static final String[] BASE_MODES = {UiSettings.BASE_NONE, UiSettings.BASE_THEME, UiSettings.BASE_COLOR}; + private static final String[] MATERIAL_PRESETS = {UiSettings.MATERIAL_BALANCED, UiSettings.MATERIAL_CLEAR, + UiSettings.MATERIAL_SOLID}; + private static final int[] FIT_LABELS = {R.string.settings_ui_fit_smart, R.string.settings_ui_fit_center, + R.string.settings_ui_fit_fit, R.string.settings_ui_fit_stretch, R.string.settings_ui_fit_tile, + R.string.settings_ui_fit_top_left, R.string.settings_ui_fit_top_right}; + + private static final int EASTER_EGG_TAP_COUNT = 3; + private static final long TAP_TIMEOUT_MS = 2000; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private PermissionsHandler permissionsHandler; private ActivityResultLauncher permissionResultLauncher; private ActivityResultLauncher bgImagePickerLauncher; + private ActivityResultLauncher bgFolderPickerLauncher; private int updateButtonTapCount = 0; private long lastUpdateButtonTapTime = 0; - private static final int EASTER_EGG_TAP_COUNT = 3; - private static final long TAP_TIMEOUT_MS = 2000; - - private TextView tabBasic; - private TextView tabPersonalize; - private TextView tabComponents; - private TextView tabOthers; - private TextView tabPrivacy; - private TextView tabUpdates; - private TextView tabAbout; - - private View sectionBasic; - private View sectionPersonalize; - private View sectionComponents; - private View sectionOthers; - private View sectionPrivacy; - private View sectionUpdates; - private View sectionAbout; - private static final String KEY_SELECTED_TAB = "selected_tab_index"; - private static final String KEY_PERSONALIZE_SECTION = "personalize_section_index"; + private PersonalizationManager personalizationManager; + private ThemeManager themeManager; + private SharedPreferences pagePrefs; + + private ScrollView scrollView; + private LinearLayout scrollContent; + private SegmentedControl settingsTabs; + private View[] sections; + private MaterialButton[] personalizeNavs; + private View[] personalizeGroups; private int selectedTabIndex = 0; private int personalizeSectionIndex = 0; - private PersonalizationManager personalizationManager; - private LinearLayout colorGridContainer; - private LinearLayout moreColorsContainer; + private EditText searchInput; + private TextView searchEmpty; + private final Runnable searchRunnable = this::runSearch; + private View highlightedRow; + private Drawable highlightedRowBackground; + + // Theme and colors + private SegmentedControl themeModeTabs; + private boolean editDark; + private LinearLayout presetGrid; + private LinearLayout moreGrid; + + // Wallpaper + private ImageView bgImagePreview; + private TextView bgFolderName; private TextView bgImageStatus; + private TextView bgImageCount; private TextView bgImageBlurValue; private TextView bgImageBrightnessValue; - private ImageView bgImagePreview; + private TextView bgImageOpacityValue; + private int folderImageCount = -1; + private int thumbnailRequest; + + // Material + private boolean materialDark; + private boolean bindingMaterial; + private TextView preloaderSigsLastUpdateText; - private Button preloaderSigsUpdateButton; + private MaterialButton preloaderSigsUpdateButton; private SettingsCard pkgSettingsSection; @Override @@ -97,14 +157,27 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_settings); DynamicAnim.applyPressScaleRecursively(findViewById(android.R.id.content)); - - setupNavBar(); + setActiveNavTab(R.id.nav_tab_settings); personalizationManager = new PersonalizationManager(this); - + themeManager = new ThemeManager(this); + pagePrefs = getSharedPreferences(PAGE_PREFS, MODE_PRIVATE); + + // Windows remembers the last settings tab and appearance section. + selectedTabIndex = pagePrefs.getInt(KEY_SELECTED_TAB, 0); + personalizeSectionIndex = pagePrefs.getInt(KEY_PERSONALIZE_SECTION, 0); + boolean dark = isDark(); + editDark = dark; + materialDark = dark; if (savedInstanceState != null) { - selectedTabIndex = savedInstanceState.getInt(KEY_SELECTED_TAB, 0); - personalizeSectionIndex = savedInstanceState.getInt(KEY_PERSONALIZE_SECTION, 0); + selectedTabIndex = savedInstanceState.getInt(KEY_SELECTED_TAB, selectedTabIndex); + personalizeSectionIndex = savedInstanceState.getInt(KEY_PERSONALIZE_SECTION, personalizeSectionIndex); + // Keep the light/dark editing tabs across a recreate, but start from the applied mode + // again when the appearance mode itself changed (as Windows does). + if (savedInstanceState.getBoolean(KEY_WAS_DARK, dark) == dark) { + editDark = savedInstanceState.getBoolean(KEY_EDIT_DARK, dark); + materialDark = savedInstanceState.getBoolean(KEY_MATERIAL_DARK, dark); + } } permissionsHandler = PermissionsHandler.getInstance(); @@ -123,35 +196,31 @@ protected void onCreate(Bundle savedInstanceState) { result -> { if (result.getResultCode() == RESULT_OK && result.getData() != null) { Uri uri = result.getData().getData(); - if (uri != null) { - setBackgroundImportState(true); - WeakReference activityRef = new WeakReference<>(this); - LauncherBackgroundController.importBackground(getApplicationContext(), uri, success -> { - SettingsActivity activity = activityRef.get(); - if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; - activity.setBackgroundImportState(false); - activity.updateBgImageUI(); - if (success) activity.recreate(); - else Toast.makeText(activity, R.string.bg_media_import_failed, Toast.LENGTH_LONG).show(); - }); - } + if (uri != null) importSingleImage(uri); } } ); + bgFolderPickerLauncher = registerForActivityResult( + new ActivityResultContracts.OpenDocumentTree(), this::onFolderPicked); initTabs(); + setupSearch(); setupBasicSection(); pkgSettingsSection = new SettingsCard(this, findViewById(R.id.pkg_settings_card)); - setupPersonalizeSection(); + setupThemeSection(); + setupLayoutSection(); + setupWallpaperSection(); + setupMaterialSection(); setupUpdatesSection(); setupAboutSection(); - TextView[] tabs = getSettingsTabs(); - if (selectedTabIndex >= tabs.length) { - selectedTabIndex = 0; - } - selectTab(tabs[selectedTabIndex]); + selectTab(selectedTabIndex, false); selectPersonalizeSection(personalizeSectionIndex); + + String query = getIntent() != null ? getIntent().getStringExtra(EXTRA_SEARCH) : null; + if (savedInstanceState == null && query != null && !query.trim().isEmpty()) { + searchInput.setText(query); + } } @Override @@ -159,107 +228,242 @@ protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(KEY_SELECTED_TAB, selectedTabIndex); outState.putInt(KEY_PERSONALIZE_SECTION, personalizeSectionIndex); + outState.putBoolean(KEY_EDIT_DARK, editDark); + outState.putBoolean(KEY_MATERIAL_DARK, materialDark); + outState.putBoolean(KEY_WAS_DARK, isDark()); + } + + private boolean isDark() { + return personalizationManager.isDarkMode(this); + } + + private int dp(float v) { + return Math.round(v * getResources().getDisplayMetrics().density); } + // #region Tabs + private void initTabs() { - tabBasic = findViewById(R.id.tab_basic); - tabPersonalize = findViewById(R.id.tab_personalize); - tabComponents = findViewById(R.id.tab_components); - tabOthers = findViewById(R.id.tab_others); - tabPrivacy = findViewById(R.id.tab_privacy); - tabUpdates = findViewById(R.id.tab_updates); - tabAbout = findViewById(R.id.tab_about); - - sectionBasic = findViewById(R.id.section_basic); - sectionPersonalize = findViewById(R.id.section_personalize); - sectionComponents = findViewById(R.id.section_components); - sectionOthers = findViewById(R.id.section_others); - sectionPrivacy = findViewById(R.id.section_privacy); - sectionUpdates = findViewById(R.id.section_updates); - sectionAbout = findViewById(R.id.section_about); - - TextView[] tabs = getSettingsTabs(); - for (int i = 0; i < tabs.length; i++) { + scrollView = findViewById(R.id.settings_scroll); + scrollContent = findViewById(R.id.settings_scroll_content); + settingsTabs = findViewById(R.id.settings_tabs); + settingsTabs.setItems( + getString(R.string.settings_tab_basic), + getString(R.string.settings_tab_personalize), + getString(R.string.settings_tab_components), + getString(R.string.settings_tab_others), + getString(R.string.settings_tab_privacy), + getString(R.string.settings_ui_tab_updates), + getString(R.string.settings_tab_about)); + settingsTabs.setOnSegmentSelectedListener(index -> selectTab(index, true)); + + sections = new View[]{ + findViewById(R.id.section_basic), + findViewById(R.id.section_personalize), + findViewById(R.id.section_components), + findViewById(R.id.section_others), + findViewById(R.id.section_privacy), + findViewById(R.id.section_updates), + findViewById(R.id.section_about)}; + + personalizeNavs = new MaterialButton[]{ + findViewById(R.id.personalize_nav_theme), + findViewById(R.id.personalize_nav_layout), + findViewById(R.id.personalize_nav_wallpaper), + findViewById(R.id.personalize_nav_material)}; + personalizeGroups = new View[]{ + findViewById(R.id.personalize_theme_group), + findViewById(R.id.personalize_layout_group), + findViewById(R.id.personalize_wallpaper_group), + findViewById(R.id.personalize_material_group)}; + for (int i = 0; i < personalizeNavs.length; i++) { final int index = i; - tabs[i].setOnClickListener(v -> { selectedTabIndex = index; selectTab(tabs[index]); }); + personalizeNavs[i].setOnClickListener(v -> selectPersonalizeSection(index)); } - - TextView navTheme = findViewById(R.id.personalize_nav_theme); - TextView navWallpaper = findViewById(R.id.personalize_nav_wallpaper); - navTheme.setOnClickListener(v -> selectPersonalizeSection(0)); - navWallpaper.setOnClickListener(v -> selectPersonalizeSection(1)); } - /** Switches between the personalization sub-sections (theme and colors / wallpaper), as on Windows. */ - private void selectPersonalizeSection(int index) { - personalizeSectionIndex = index; - TextView[] navs = {findViewById(R.id.personalize_nav_theme), findViewById(R.id.personalize_nav_wallpaper)}; - View[] groups = {findViewById(R.id.personalize_theme_group), findViewById(R.id.personalize_wallpaper_group)}; - int accent = personalizationManager.getAccentColor(); - for (int i = 0; i < navs.length; i++) { - boolean selected = i == index; - if (selected) { - if (accent != 0) { - GradientDrawable gd = new GradientDrawable(); - gd.setShape(GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(16 * getResources().getDisplayMetrics().density); - navs[i].setBackground(gd); + private void selectTab(int index, boolean animate) { + if (index < 0 || index >= sections.length) index = 0; + selectedTabIndex = index; + pagePrefs.edit().putInt(KEY_SELECTED_TAB, index).apply(); + settingsTabs.setSelectedIndex(index, false); + for (int i = 0; i < sections.length; i++) { + View section = sections[i]; + if (i == index) { + section.setVisibility(View.VISIBLE); + if (animate && !UiSettings.animationsDisabled()) { + section.setAlpha(0f); + section.animate().alpha(1f).setDuration(200).start(); } else { - navs[i].setBackgroundResource(R.drawable.bg_tab_selected); + section.setAlpha(1f); } - navs[i].setTextColor(Color.WHITE); } else { - navs[i].setBackgroundResource(R.drawable.bg_tab_unselected); - navs[i].setTextColor(getColor(R.color.text_secondary)); + section.setVisibility(View.GONE); } - groups[i].setVisibility(selected ? View.VISIBLE : View.GONE); } } - private void selectTab(TextView selectedTab) { - TextView[] tabs = getSettingsTabs(); - View[] sections = {sectionBasic, sectionPersonalize, sectionComponents, sectionOthers, sectionPrivacy, - sectionUpdates, sectionAbout}; - - int accent = personalizationManager.getAccentColor(); + /** Personalization sub-sections, a button row as on Windows: selected = primary, others secondary. */ + private void selectPersonalizeSection(int index) { + if (index < 0 || index >= personalizeGroups.length) index = 0; + personalizeSectionIndex = index; + pagePrefs.edit().putInt(KEY_PERSONALIZE_SECTION, index).apply(); + ColorStateList secondaryFill = ColorStateList.valueOf(getColor(R.color.ui_default)); + ColorStateList secondaryText = ColorStateList.valueOf(getColor(R.color.text_primary)); + for (int i = 0; i < personalizeNavs.length; i++) { + MaterialButton nav = personalizeNavs[i]; + if (i == index) { + UiKit.applyPrimary(nav); + } else { + nav.setBackgroundTintList(secondaryFill); + nav.setTextColor(secondaryText); + } + personalizeGroups[i].setVisibility(i == index ? View.VISIBLE : View.GONE); + } + if (index == SECTION_WALLPAPER) updateBgImageUI(); + } - for (int i = 0; i < tabs.length; i++) { - boolean isSelected = tabs[i] == selectedTab; + // #endregion + + // #region Search + + private void setupSearch() { + searchInput = findViewById(R.id.settings_search); + searchEmpty = findViewById(R.id.settings_search_empty); + // A restored query would jump around again after recreate(). + searchInput.setSaveEnabled(false); + searchInput.addTextChangedListener(SimpleTextWatcher.after(s -> { + handler.removeCallbacks(searchRunnable); + handler.postDelayed(searchRunnable, 300); + })); + searchInput.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) { + handler.removeCallbacks(searchRunnable); + runSearch(); + InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); + if (imm != null) imm.hideSoftInputFromWindow(v.getWindowToken(), 0); + return true; + } + return false; + }); + } - if (isSelected) { - if (accent != 0) { - android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable(); - gd.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(16 * getResources().getDisplayMetrics().density); - tabs[i].setBackground(gd); - } else { - tabs[i].setBackgroundResource(R.drawable.bg_tab_selected); + /** Opens the tab (and appearance section) of the first setting that matches, and scrolls to it. */ + private void runSearch() { + String query = searchInput.getText().toString().trim().toLowerCase(Locale.getDefault()); + searchEmpty.setVisibility(View.GONE); + if (query.isEmpty()) return; + + for (int tab = 0; tab < sections.length; tab++) { + List rows = new ArrayList<>(); + collectRows(sections[tab], rows); + for (View row : rows) { + if (!isRowAvailable(row, sections[tab])) continue; + if (textOf(row).contains(query)) { + jumpTo(tab, subSectionOf(row), row); + return; } - tabs[i].setTextColor(Color.WHITE); - tabs[i].setTextSize(13); - } else { - tabs[i].setBackgroundResource(R.drawable.bg_tab_unselected); - tabs[i].setTextColor(getColor(R.color.text_secondary)); } - tabs[i].setTypeface(android.graphics.Typeface.create(tabs[i].getTypeface(), - isSelected ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); - - if (isSelected) { - sections[i].setVisibility(View.VISIBLE); - sections[i].setAlpha(0f); - sections[i].animate().alpha(1f).setDuration(200).start(); - } else { - sections[i].setVisibility(View.GONE); + if (tab == TAB_PERSONALIZE) { + for (int i = 0; i < personalizeNavs.length; i++) { + if (personalizeNavs[i].getText().toString().toLowerCase(Locale.getDefault()).contains(query)) { + jumpTo(tab, i, null); + return; + } + } } + if (settingsTabs.getSegment(tab).getText().toString().toLowerCase(Locale.getDefault()).contains(query)) { + jumpTo(tab, -1, null); + return; + } + } + searchEmpty.setVisibility(View.VISIBLE); + } + + private void collectRows(View view, List out) { + if (ROW_TAG.equals(view.getTag())) { + out.add(view); + return; + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) collectRows(group.getChildAt(i), out); } } - private TextView[] getSettingsTabs() { - return new TextView[]{tabBasic, tabPersonalize, tabComponents, tabOthers, tabPrivacy, tabUpdates, tabAbout}; + /** False when the row is hidden by its own state (e.g. wallpaper sliders without a wallpaper). */ + private boolean isRowAvailable(View row, View section) { + View v = row; + while (v != null && v != section) { + boolean switchable = false; + for (View group : personalizeGroups) if (group == v) switchable = true; + if (!switchable && v.getVisibility() == View.GONE) return false; + v = v.getParent() instanceof View ? (View) v.getParent() : null; + } + return true; } + private int subSectionOf(View row) { + View v = row; + while (v != null) { + for (int i = 0; i < personalizeGroups.length; i++) if (personalizeGroups[i] == v) return i; + v = v.getParent() instanceof View ? (View) v.getParent() : null; + } + return -1; + } + + private String textOf(View view) { + StringBuilder sb = new StringBuilder(); + appendText(view, sb); + return sb.toString().toLowerCase(Locale.getDefault()); + } + + private void appendText(View view, StringBuilder sb) { + if (view instanceof EditText) return; + if (view instanceof TextView) { + sb.append(((TextView) view).getText()).append(' '); + } else if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) appendText(group.getChildAt(i), sb); + } + } + + private void jumpTo(int tab, int subSection, View row) { + if (tab != selectedTabIndex) selectTab(tab, false); + if (subSection >= 0 && subSection != personalizeSectionIndex) selectPersonalizeSection(subSection); + scrollView.post(() -> { + if (row == null) { + scrollView.smoothScrollTo(0, 0); + return; + } + Rect rect = new Rect(); + row.getDrawingRect(rect); + scrollContent.offsetDescendantRectToMyCoords(row, rect); + scrollView.smoothScrollTo(0, Math.max(0, rect.top - dp(16))); + highlightRow(row); + }); + } + + private void highlightRow(View row) { + clearHighlight(); + highlightedRow = row; + highlightedRowBackground = row.getBackground(); + row.setBackground(UiKit.selectedRing(this, 12)); + handler.postDelayed(this::clearHighlight, 1600); + } + + private void clearHighlight() { + if (highlightedRow != null) { + highlightedRow.setBackground(highlightedRowBackground); + highlightedRow = null; + highlightedRowBackground = null; + } + } + + // #endregion + + // #region Basic + private void setupBasicSection() { LanguageManager languageManager = new LanguageManager(this); FeatureSettings fs = FeatureSettings.getInstance(); @@ -279,6 +483,7 @@ private void setupBasicSection() { getString(R.string.turkish), getString(R.string.vietnamese) }; + String[] languageCodes = {"en", "zh-CN", "fr", "hi", "id", "ja", "pt", "ru", "es", "tr", "vi"}; String currentCode = Locale.forLanguageTag(languageManager.getCurrentLanguage()).getLanguage(); int defaultIdx = switch (currentCode) { @@ -297,39 +502,18 @@ private void setupBasicSection() { TextView languageCurrent = findViewById(R.id.language_current); languageCurrent.setText(languageOptions[defaultIdx]); - - Spinner languageSpinner = findViewById(R.id.language_spinner); - ArrayAdapter langAdapter = new ArrayAdapter<>(this, R.layout.spinner_item, languageOptions); - langAdapter.setDropDownViewResource(R.layout.spinner_dropdown_item); - languageSpinner.setAdapter(langAdapter); - languageSpinner.setPopupBackgroundResource(R.drawable.bg_popup_menu_rounded); - languageSpinner.setSelection(defaultIdx); - languageSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - String code = switch (position) { - case 1 -> "zh-CN"; - case 2 -> "fr"; - case 3 -> "hi"; - case 4 -> "id"; - case 5 -> "ja"; - case 6 -> "pt"; - case 7 -> "ru"; - case 8 -> "es"; - case 9 -> "tr"; - case 10 -> "vi"; - default -> "en"; - }; - if (!code.equals(languageManager.getCurrentLanguage())) { - languageManager.setAppLanguage(code); - } - languageCurrent.setText(languageOptions[position]); - } - - @Override - public void onNothingSelected(AdapterView parent) { - } - }); + // Windows: the row shows the current language and "Change" opens the choice. + findViewById(R.id.language_change).setOnClickListener(v -> new CustomAlertDialog(this) + .setTitleText(getString(R.string.language)) + .setItems(languageOptions, (dialog, which) -> { + String code = languageCodes[which]; + languageCurrent.setText(languageOptions[which]); + if (!code.equals(languageManager.getCurrentLanguage())) { + languageManager.setAppLanguage(code); + } + }) + .setNegativeButton(getString(R.string.cancel), null) + .show()); SwitchMaterial switchLogcat = findViewById(R.id.switch_logcat); switchLogcat.setChecked(fs.isLogcatOverlayEnabled()); @@ -361,416 +545,829 @@ public void onNothingSelected(AdapterView parent) { switchManagedLogin.setOnCheckedChangeListener((btn, checked) -> fs.setLauncherManagedMcLoginEnabled(checked)); } - private void setupPersonalizeSection() { - ThemeManager themeManager = new ThemeManager(this); + // #endregion + + // #region Theme and colors + + private void setupThemeSection() { + themeModeTabs = findViewById(R.id.theme_mode_tabs); + themeModeTabs.addSegment(getString(R.string.settings_ui_mode_light), R.drawable.ic_sun); + themeModeTabs.addSegment(getString(R.string.settings_ui_mode_dark), R.drawable.ic_moon); + themeModeTabs.addSegment(getString(R.string.settings_ui_mode_schedule), R.drawable.ic_ui_schedule); + themeModeTabs.addSegment(getString(R.string.settings_ui_mode_sun), R.drawable.ic_ui_sunrise); + themeModeTabs.addSegment(getString(R.string.settings_ui_mode_system), R.drawable.ic_ui_monitor); + int mode = themeManager.getCurrentMode(); + themeModeTabs.setSelectedIndex(themeModeIndex(mode), false); + themeModeTabs.setOnSegmentSelectedListener(index -> { + updateThemeModePanel(THEME_MODES[index]); + themeManager.setThemeMode(THEME_MODES[index]); + }); + updateThemeModePanel(mode); - View itemSystem = findViewById(R.id.theme_item_system); - View itemLight = findViewById(R.id.theme_item_light); - View itemDark = findViewById(R.id.theme_item_dark); + findViewById(R.id.theme_schedule_start).setOnClickListener(v -> pickScheduleTime(true)); + findViewById(R.id.theme_schedule_end).setOnClickListener(v -> pickScheduleTime(false)); + findViewById(R.id.theme_sun_refresh).setOnClickListener(v -> { + updateThemeModePanel(themeManager.getCurrentMode()); + if (themeManager.refreshScheduledMode()) recreate(); + }); + UiKit.tintIconChip(findViewById(R.id.theme_sunrise_chip), findViewById(R.id.theme_sunrise_icon), UiKit.AMBER); + UiKit.tintIconChip(findViewById(R.id.theme_sunset_chip), findViewById(R.id.theme_sunset_icon), UiKit.INDIGO); + + View bar = findViewById(R.id.mode_config_bar); + bar.setBackground(roundRect(UiKit.accent(this), 2)); + + SegmentedControl editTabs = findViewById(R.id.edit_mode_tabs); + editTabs.setEqualWidth(false); + editTabs.setItems(getString(R.string.settings_ui_mode_light), getString(R.string.settings_ui_mode_dark)); + editTabs.setSelectedIndex(editDark ? 1 : 0, false); + editTabs.setOnSegmentSelectedListener(index -> { + editDark = index == 1; + refreshEditMode(); + }); + + presetGrid = findViewById(R.id.color_preset_grid); + moreGrid = findViewById(R.id.color_more_grid); + View.OnLayoutChangeListener widthListener = (v, l, t, r, b, ol, ot, or, ob) -> { + if (r - l != or - ol) v.post(this::rebuildSwatches); + }; + presetGrid.addOnLayoutChangeListener(widthListener); + moreGrid.addOnLayoutChangeListener(widthListener); + + SegmentedControl baseTabs = findViewById(R.id.base_mode_tabs); + baseTabs.setItems(getString(R.string.settings_ui_base_none), getString(R.string.settings_ui_base_theme), + getString(R.string.settings_ui_base_color)); + baseTabs.setOnSegmentSelectedListener(index -> { + UiSettings.setBase(this, editDark, BASE_MODES[index], UiSettings.getBaseColor(this, editDark), + UiSettings.getBaseOpacity(this, editDark)); + onBaseChanged(); + }); + EditText baseInput = findViewById(R.id.base_color_input); + findViewById(R.id.base_color_apply).setOnClickListener(v -> { + Integer color = parseColorInput(baseInput.getText().toString()); + if (color == null) { + Toast.makeText(this, R.string.invalid_color_format, Toast.LENGTH_SHORT).show(); + return; + } + UiSettings.setBase(this, editDark, UiSettings.BASE_COLOR, color, UiSettings.getBaseOpacity(this, editDark)); + baseInput.clearFocus(); + onBaseChanged(); + }); + SeekBar baseOpacity = findViewById(R.id.base_opacity_seek); + baseOpacity.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setValueText(R.id.base_opacity_value, R.string.settings_ui_value_percent, progress); + if (!fromUser) return; + UiSettings.setBase(SettingsActivity.this, editDark, UiSettings.getBaseMode(SettingsActivity.this, editDark), + UiSettings.getBaseColor(SettingsActivity.this, editDark), progress); + if (editDark == isDark()) personalizationManager.refreshBackgroundEffects(SettingsActivity.this); + } + }); - refreshThemeSelectionUI(); + refreshEditMode(); + } - if (itemSystem != null && itemLight != null && itemDark != null) { - itemSystem.setOnClickListener(v -> { themeManager.setThemeMode(0); }); - itemLight.setOnClickListener(v -> { themeManager.setThemeMode(1); }); - itemDark.setOnClickListener(v -> { themeManager.setThemeMode(2); }); + private static int themeModeIndex(int mode) { + for (int i = 0; i < THEME_MODES.length; i++) if (THEME_MODES[i] == mode) return i; + return THEME_MODES.length - 1; + } + + private void updateThemeModePanel(int mode) { + View panel = findViewById(R.id.theme_mode_panel); + TextView desc = findViewById(R.id.theme_mode_panel_desc); + View schedule = findViewById(R.id.theme_schedule_box); + View sun = findViewById(R.id.theme_sun_box); + boolean showSchedule = mode == ThemeManager.MODE_SCHEDULE; + boolean showSun = mode == ThemeManager.MODE_SUN; + boolean showSystem = mode == ThemeManager.MODE_FOLLOW_SYSTEM; + panel.setVisibility(showSchedule || showSun || showSystem ? View.VISIBLE : View.GONE); + schedule.setVisibility(showSchedule ? View.VISIBLE : View.GONE); + sun.setVisibility(showSun ? View.VISIBLE : View.GONE); + if (showSchedule) { + desc.setText(R.string.settings_ui_schedule_desc); + ((TextView) findViewById(R.id.theme_schedule_start)).setText(formatClock(themeManager.getScheduleStart())); + ((TextView) findViewById(R.id.theme_schedule_end)).setText(formatClock(themeManager.getScheduleEnd())); + } else if (showSun) { + desc.setText(R.string.settings_ui_sun_desc); + String[] times = ThemeManager.getSunTimes(); + ((TextView) findViewById(R.id.theme_sunrise_time)).setText(formatClock(times[0])); + ((TextView) findViewById(R.id.theme_sunset_time)).setText(formatClock(times[1])); + } else if (showSystem) { + desc.setText(R.string.settings_ui_mode_system_desc); } + } - setupColorPicker(); - setupBackgroundImagePicker(); + private String formatClock(String hhmm) { + int minutes = ThemeManager.parseMinutes(hhmm); + if (minutes < 0) return hhmm; + Calendar c = Calendar.getInstance(); + c.set(Calendar.HOUR_OF_DAY, minutes / 60); + c.set(Calendar.MINUTE, minutes % 60); + return android.text.format.DateFormat.getTimeFormat(this).format(c.getTime()); } - private void refreshThemeSelectionUI() { - ThemeManager themeManager = new ThemeManager(this); - int currentMode = themeManager.getCurrentMode(); + private void pickScheduleTime(boolean start) { + String current = start ? themeManager.getScheduleStart() : themeManager.getScheduleEnd(); + int minutes = ThemeManager.parseMinutes(current); + if (minutes < 0) minutes = start ? 20 * 60 : 7 * 60; + new TimePickerDialog(this, (view, hour, minute) -> { + String value = String.format(Locale.ROOT, "%02d:%02d", hour, minute); + boolean wasDark = PersonalizationManager.isDarkActive(this); + if (start) themeManager.setSchedule(value, themeManager.getScheduleEnd()); + else themeManager.setSchedule(themeManager.getScheduleStart(), value); + updateThemeModePanel(themeManager.getCurrentMode()); + if (wasDark != PersonalizationManager.isDarkActive(this)) recreate(); + }, minutes / 60, minutes % 60, android.text.format.DateFormat.is24HourFormat(this)).show(); + } - TextView textSystem = findViewById(R.id.theme_text_system); - TextView textLight = findViewById(R.id.theme_text_light); - TextView textDark = findViewById(R.id.theme_text_dark); + /** The light/dark tab of "按模式分别设置" decides which mode the color and base edits apply to. */ + private void refreshEditMode() { + ImageView icon = findViewById(R.id.edit_mode_icon); + icon.setImageResource(editDark ? R.drawable.ic_moon : R.drawable.ic_sun); + icon.setImageTintList(ColorStateList.valueOf(editDark ? UiKit.INDIGO : UiKit.AMBER)); + ((TextView) findViewById(R.id.edit_mode_title)) + .setText(editDark ? R.string.settings_ui_mode_dark : R.string.settings_ui_mode_light); + rebuildSwatches(); + refreshBaseUI(); + } - ImageView iconSystem = findViewById(R.id.theme_icon_system); - ImageView iconLight = findViewById(R.id.theme_icon_light); - ImageView iconDark = findViewById(R.id.theme_icon_dark); + private void rebuildSwatches() { + if (presetGrid == null) return; + int selected = personalizationManager.getAccentColor(editDark) | 0xFF000000; + boolean custom = !contains(PersonalizationManager.PRESET_COLORS, selected) + && !contains(PersonalizationManager.MORE_COLORS, selected); + fillSwatchGrid(presetGrid, PersonalizationManager.PRESET_COLORS, false, selected, custom); + fillSwatchGrid(moreGrid, PersonalizationManager.MORE_COLORS, true, selected, custom); + } - int accent = personalizationManager.getAccentColor(); - int selectedColor = accent != 0 ? accent : getColor(R.color.on_surface); - int unselectedColor = getColor(R.color.text_secondary); + private static boolean contains(int[] colors, int color) { + for (int c : colors) if ((c | 0xFF000000) == color) return true; + return false; + } - if (textSystem != null) { - textSystem.setTypeface(null, currentMode == 0 ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL); - textSystem.setTextColor(currentMode == 0 ? selectedColor : getColor(R.color.on_surface)); - } - if (textLight != null) { - textLight.setTypeface(null, currentMode == 1 ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL); - textLight.setTextColor(currentMode == 1 ? selectedColor : getColor(R.color.on_surface)); - } - if (textDark != null) { - textDark.setTypeface(null, currentMode == 2 ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL); - textDark.setTextColor(currentMode == 2 ? selectedColor : getColor(R.color.on_surface)); + private void fillSwatchGrid(LinearLayout grid, int[] colors, boolean withCustom, int selected, boolean custom) { + int available = grid.getWidth() - grid.getPaddingLeft() - grid.getPaddingRight(); + if (available <= 0) return; + grid.removeAllViews(); + int perRow = Math.max(1, available / dp(40)); + int total = colors.length + (withCustom ? 1 : 0); + LinearLayout row = null; + for (int i = 0; i < total; i++) { + if (i % perRow == 0) { + row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + grid.addView(row, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + row.addView(i < colors.length + ? makeSwatch(colors[i] | 0xFF000000, (colors[i] | 0xFF000000) == selected) + : makeCustomSwatch(selected, custom)); } + } - if (iconSystem != null) iconSystem.setImageTintList(android.content.res.ColorStateList.valueOf(currentMode == 0 ? selectedColor : unselectedColor)); - if (iconLight != null) iconLight.setImageTintList(android.content.res.ColorStateList.valueOf(currentMode == 1 ? selectedColor : unselectedColor)); - if (iconDark != null) iconDark.setImageTintList(android.content.res.ColorStateList.valueOf(currentMode == 2 ? selectedColor : unselectedColor)); + /** 32dp color circle in a 40dp touch cell; the selected one gets a ring and a check (Windows swatch). */ + private View makeSwatch(int color, boolean selected) { + FrameLayout cell = swatchCell(color, selected); + if (selected) cell.addView(swatchIcon(R.drawable.ic_check, ThemePalette.onAccent(color))); + cell.setContentDescription(String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF)); + cell.setOnClickListener(v -> setAccent(color)); + DynamicAnim.applyPressScale(cell); + return cell; } - private void setupColorPicker() { - colorGridContainer = findViewById(R.id.color_preset_grid); - moreColorsContainer = findViewById(R.id.color_more_grid); + private View makeCustomSwatch(int selected, boolean custom) { + int fill = custom ? selected : getColor(R.color.ui_default); + FrameLayout cell = swatchCell(fill, custom); + cell.addView(swatchIcon(R.drawable.ic_ui_palette, + custom ? ThemePalette.onAccent(selected) : getColor(R.color.text_secondary))); + cell.setContentDescription(getString(R.string.settings_ui_custom_color)); + cell.setOnClickListener(v -> showCustomColorDialog(custom ? selected : UiKit.accent(this))); + DynamicAnim.applyPressScale(cell); + return cell; + } - if (colorGridContainer != null && moreColorsContainer != null) { - int currentAccent = personalizationManager.getAccentColor(); - buildColorGrid(colorGridContainer, PersonalizationManager.PRESET_COLORS, currentAccent); - buildColorGrid(moreColorsContainer, PersonalizationManager.MORE_COLORS, currentAccent); + private FrameLayout swatchCell(int color, boolean selected) { + FrameLayout cell = new FrameLayout(this); + cell.setLayoutParams(new LinearLayout.LayoutParams(dp(40), dp(40))); + if (selected) { + GradientDrawable ring = new GradientDrawable(); + ring.setShape(GradientDrawable.OVAL); + ring.setColor(Color.TRANSPARENT); + ring.setStroke(dp(2), color); + cell.setBackground(ring); } + View circle = new View(this); + GradientDrawable dot = new GradientDrawable(); + dot.setShape(GradientDrawable.OVAL); + dot.setColor(color); + circle.setBackground(dot); + cell.addView(circle, new FrameLayout.LayoutParams(dp(selected ? 30 : 32), dp(selected ? 30 : 32), Gravity.CENTER)); + cell.setClickable(true); + cell.setFocusable(true); + return cell; + } + + private ImageView swatchIcon(int res, int tint) { + ImageView icon = new ImageView(this); + icon.setImageResource(res); + icon.setImageTintList(ColorStateList.valueOf(tint)); + icon.setLayoutParams(new FrameLayout.LayoutParams(dp(16), dp(16), Gravity.CENTER)); + return icon; + } - android.widget.EditText inputCustomColor = findViewById(R.id.input_custom_color); - Button btnApplyColor = findViewById(R.id.btn_apply_color); - if (inputCustomColor != null && btnApplyColor != null) { - btnApplyColor.setOnClickListener(v -> { - String input = inputCustomColor.getText().toString().trim(); - try { - int color; - if (input.startsWith("#")) { - color = Color.parseColor(input); - } else if (input.contains(",")) { - String[] parts = input.split(","); - if (parts.length == 3) { - color = Color.rgb(Integer.parseInt(parts[0].trim()), - Integer.parseInt(parts[1].trim()), - Integer.parseInt(parts[2].trim())); - } else { - throw new IllegalArgumentException("Invalid RGB format"); - } + private void setAccent(int color) { + personalizationManager.setAccentColor(editDark, color); + // The color of the mode in use repaints the whole page; the other mode's color only updates the grid. + if (editDark == isDark()) recreate(); + else rebuildSwatches(); + } + + private void showCustomColorDialog(int initial) { + EditText input = new EditText(this, null, 0, R.style.Widget_Levi_Input); + input.setHint(R.string.custom_color_hint); + input.setText(String.format(Locale.ROOT, "#%06X", initial & 0xFFFFFF)); + input.setSingleLine(true); + input.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); + LinearLayout box = new LinearLayout(this); + box.setOrientation(LinearLayout.VERTICAL); + box.setPadding(0, dp(4), 0, dp(4)); + box.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(44))); + new CustomAlertDialog(this) + .setTitleText(getString(R.string.settings_ui_custom_color)) + .setCustomView(box) + .setPositiveButton(getString(R.string.apply), v -> { + Integer color = parseColorInput(input.getText().toString()); + if (color == null) { + Toast.makeText(this, R.string.invalid_color_format, Toast.LENGTH_SHORT).show(); } else { - color = Color.parseColor("#" + input); + setAccent(color); } - personalizationManager.setAccentColor(color); - refreshColorPickerInPlace(); - } catch (Exception e) { - Toast.makeText(this, R.string.invalid_color_format, Toast.LENGTH_SHORT).show(); - } - }); - } + }) + .setNegativeButton(getString(R.string.cancel), null) + .show(); } - private void buildColorGrid(LinearLayout container, int[] colors, int selectedColor) { - container.removeAllViews(); + /** Accepts "#RRGGBB", "RRGGBB" or "R,G,B"; null when invalid. */ + private static Integer parseColorInput(String raw) { + String input = raw.trim(); + try { + if (input.startsWith("#")) return Color.parseColor(input) | 0xFF000000; + if (input.contains(",")) { + String[] parts = input.split(","); + if (parts.length != 3) return null; + int r = Integer.parseInt(parts[0].trim()); + int g = Integer.parseInt(parts[1].trim()); + int b = Integer.parseInt(parts[2].trim()); + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) return null; + return Color.rgb(r, g, b); + } + return Color.parseColor("#" + input) | 0xFF000000; + } catch (Exception e) { + return null; + } + } - float density = getResources().getDisplayMetrics().density; - int circleSize = (int) (32 * density); - int margin = (int) (4 * density); - int checkSize = (int) (14 * density); + private void onBaseChanged() { + refreshBaseUI(); + if (editDark == isDark()) personalizationManager.refreshBackgroundEffects(this); + } - int columns = 15; + private void refreshBaseUI() { + String mode = UiSettings.getBaseMode(this, editDark); + int color = UiSettings.getBaseColor(this, editDark); + int opacity = UiSettings.getBaseOpacity(this, editDark); int index = 0; - while (index < colors.length) { - LinearLayout row = new LinearLayout(this); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setLayoutParams(new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT)); - - for (int col = 0; col < columns && index < colors.length; col++, index++) { - int color = colors[index]; - - FrameLayout wrapper = new FrameLayout(this); - LinearLayout.LayoutParams wrapParams = new LinearLayout.LayoutParams(circleSize, circleSize); - wrapParams.setMargins(margin, margin, margin, margin); - wrapper.setLayoutParams(wrapParams); - - View circle = new View(this); - circle.setLayoutParams(new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - GradientDrawable circleDrawable = new GradientDrawable(); - circleDrawable.setShape(GradientDrawable.OVAL); - circleDrawable.setColor(color); - if (color == selectedColor) { - circleDrawable.setStroke((int) (2 * density), Color.WHITE); - } - circle.setBackground(circleDrawable); - wrapper.addView(circle); - - if (color == selectedColor) { - ImageView check = new ImageView(this); - FrameLayout.LayoutParams checkParams = new FrameLayout.LayoutParams(checkSize, checkSize); - checkParams.gravity = Gravity.CENTER; - check.setLayoutParams(checkParams); - check.setImageResource(R.drawable.ic_check); - check.setColorFilter(Color.WHITE); - wrapper.addView(check); - } - - wrapper.setClickable(true); - wrapper.setFocusable(true); - final int finalColor = color; - wrapper.setOnClickListener(v -> { - personalizationManager.setAccentColor(finalColor); - refreshColorPickerInPlace(); - }); - DynamicAnim.applyPressScale(wrapper); - - row.addView(wrapper); - } + for (int i = 0; i < BASE_MODES.length; i++) if (BASE_MODES[i].equals(mode)) index = i; + ((SegmentedControl) findViewById(R.id.base_mode_tabs)).setSelectedIndex(index, false); + findViewById(R.id.base_color_box).setVisibility(UiSettings.BASE_COLOR.equals(mode) ? View.VISIBLE : View.GONE); + findViewById(R.id.base_opacity_box).setVisibility(UiSettings.BASE_NONE.equals(mode) ? View.GONE : View.VISIBLE); + + String hex = String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); + ((TextView) findViewById(R.id.base_color_value)).setText(hex); + GradientDrawable swatch = roundRect(color, 12); + swatch.setStroke(dp(1), getColor(R.color.ui_border)); + findViewById(R.id.base_color_swatch).setBackground(swatch); + EditText input = findViewById(R.id.base_color_input); + if (!input.hasFocus()) input.setText(hex); + + SeekBar seek = findViewById(R.id.base_opacity_seek); + seek.setProgress(opacity); + setValueText(R.id.base_opacity_value, R.string.settings_ui_value_percent, opacity); + } - container.addView(row); - } + private GradientDrawable roundRect(int color, float radiusDp) { + GradientDrawable gd = new GradientDrawable(); + gd.setCornerRadius(dp(radiusDp)); + gd.setColor(color); + return gd; + } + private void setValueText(int viewId, int formatRes, int value) { + TextView view = findViewById(viewId); + if (view != null) view.setText(getString(formatRes, value)); } - private void refreshColorPickerInPlace() { - setupColorPicker(); - PersonalizationManager pm = new PersonalizationManager(this); - int accent = pm.getAccentColor(); - - pm.applyToActivity(this); + // #endregion - refreshThemeSelectionUI(); - - TextView[] tabs = getSettingsTabs(); - selectTab(tabs[selectedTabIndex]); - selectPersonalizeSection(personalizeSectionIndex); - - View settingsTitle = findViewById(R.id.settings_title); - if (settingsTitle instanceof TextView && accent != 0) { - ((TextView) settingsTitle).setTextColor(accent); - } - - Button btnSelectImage = findViewById(R.id.btn_select_bg_image); - if (btnSelectImage != null && accent != 0) { - btnSelectImage.setBackgroundTintList(ColorStateList.valueOf(accent)); - btnSelectImage.setTextColor(Color.WHITE); - } - - Button btnCheckUpdate = findViewById(R.id.btn_check_update); - if (btnCheckUpdate != null && accent != 0) { - btnCheckUpdate.setBackgroundTintList(ColorStateList.valueOf(accent)); - btnCheckUpdate.setTextColor(Color.WHITE); - } - - Button btnUpdatePreloaderSigs = findViewById(R.id.btn_update_preloader_sigs); - if (btnUpdatePreloaderSigs != null && accent != 0) { - btnUpdatePreloaderSigs.setBackgroundTintList(ColorStateList.valueOf(accent)); - btnUpdatePreloaderSigs.setTextColor(Color.WHITE); - } - - SwitchMaterial switchLogcat = findViewById(R.id.switch_logcat); - if (switchLogcat != null && accent != 0) { - int[][] states = {{android.R.attr.state_checked}, {}}; - switchLogcat.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent)); - switchLogcat.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); - } - - SwitchMaterial switchDiscordPresence = findViewById(R.id.switch_discord_presence); - if (switchDiscordPresence != null && accent != 0) { - int[][] states = {{android.R.attr.state_checked}, {}}; - switchDiscordPresence.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent)); - switchDiscordPresence.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); - } - - SwitchMaterial switchManagedLogin = findViewById(R.id.switch_managed_login); - if (switchManagedLogin != null && accent != 0) { - int[][] states = {{android.R.attr.state_checked}, {}}; - switchManagedLogin.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent)); - switchManagedLogin.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); - } + // #region Layout and motion - if (pkgSettingsSection != null) { - pkgSettingsSection.applyAccent(); - } + private void setupLayoutSection() { + SwitchMaterial navbar = findViewById(R.id.switch_top_navbar); + navbar.setChecked(UiSettings.useTopNavbar(this)); + navbar.setOnCheckedChangeListener((btn, checked) -> { + UiSettings.setLayoutMode(this, checked ? UiSettings.LAYOUT_NAVBAR : UiSettings.LAYOUT_SIDEBAR); + recreate(); + }); - SwitchMaterial switchCrashUpload = findViewById(R.id.switch_crash_upload); - if (switchCrashUpload != null && accent != 0) { - int[][] states = {{android.R.attr.state_checked}, {}}; - switchCrashUpload.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent)); - switchCrashUpload.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); - } - - TextView navAppName = findViewById(R.id.nav_app_name); - if (navAppName != null && accent != 0) { - pm.applySolidAccentText(navAppName, accent); - } - - Button navSignInBtn = findViewById(R.id.nav_sign_in_button); - if (navSignInBtn != null && accent != 0) { - navSignInBtn.setBackgroundTintList(ColorStateList.valueOf(accent)); - navSignInBtn.setTextColor(Color.WHITE); - } - - int[] navTabIds = {R.id.nav_tab_launch, R.id.nav_tab_instances, R.id.nav_tab_about, R.id.nav_tab_settings}; - for (int id : navTabIds) { - TextView navTab = findViewById(id); - if (navTab != null && id == R.id.nav_tab_settings && accent != 0) { - navTab.setTextColor(accent); - navTab.setTypeface(navTab.getTypeface(), android.graphics.Typeface.BOLD); - androidx.core.widget.TextViewCompat.setCompoundDrawableTintList(navTab, ColorStateList.valueOf(accent)); - } - } + SwitchMaterial animations = findViewById(R.id.switch_disable_animations); + animations.setChecked(UiSettings.isAnimationsDisabledSetting(this)); + animations.setOnCheckedChangeListener((btn, checked) -> UiSettings.setAnimationsDisabled(this, checked)); } - private void setupBackgroundImagePicker() { - bgImageStatus = findViewById(R.id.bg_image_status); - bgImagePreview = findViewById(R.id.bg_image_preview); - Button btnSelectImage = findViewById(R.id.btn_select_bg_image); - Button btnClearImage = findViewById(R.id.btn_clear_bg_image); + // #endregion - if (btnSelectImage == null) return; + // #region Wallpaper - setupBackgroundImageControls(); - updateBgImageUI(); - setBackgroundImportState(LauncherBackgroundController.isImporting()); + private void setupWallpaperSection() { + bgImagePreview = findViewById(R.id.bg_image_preview); + bgImagePreview.setClipToOutline(true); + bgFolderName = findViewById(R.id.bg_folder_name); + bgImageStatus = findViewById(R.id.bg_image_status); + bgImageCount = findViewById(R.id.bg_image_count); - btnSelectImage.setOnClickListener(v -> { + findViewById(R.id.btn_select_bg_folder).setOnClickListener(v -> { + try { + bgFolderPickerLauncher.launch(null); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, R.string.settings_ui_folder_failed, Toast.LENGTH_SHORT).show(); + } + }); + findViewById(R.id.btn_select_bg_image).setOnClickListener(v -> { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("image/*"); intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); bgImagePickerLauncher.launch(intent); }); - - if (btnClearImage != null) { - btnClearImage.setOnClickListener(v -> { - personalizationManager.clearBackgroundImage(); - updateBgImageUI(); - recreate(); + findViewById(R.id.btn_clear_bg_image).setOnClickListener(v -> { + String folder = personalizationManager.getBackgroundFolder(); + personalizationManager.setBackgroundFolder(null); + releaseFolderPermission(this, folder); + personalizationManager.clearBackgroundImage(); + recreate(); + }); + findViewById(R.id.btn_next_bg_image).setOnClickListener(v -> showNextWallpaper()); + + SegmentedControl order = findViewById(R.id.bg_play_order); + order.setItems(getString(R.string.settings_ui_play_random), getString(R.string.settings_ui_play_sequential)); + order.setSelectedIndex(PersonalizationManager.ORDER_SEQUENTIAL.equals( + personalizationManager.getBackgroundPlayOrder()) ? 1 : 0, false); + order.setOnSegmentSelectedListener(index -> personalizationManager.setBackgroundPlayOrder( + index == 1 ? PersonalizationManager.ORDER_SEQUENTIAL : PersonalizationManager.ORDER_RANDOM)); + + TextView fit = findViewById(R.id.bg_fit_mode); + fit.setText(fitLabel(personalizationManager.getBackgroundFitMode())); + fit.setOnClickListener(v -> { + PopupMenu menu = new PopupMenu(this, v); + for (int i = 0; i < FIT_LABELS.length; i++) menu.getMenu().add(0, i, i, FIT_LABELS[i]); + menu.setOnMenuItemClickListener(item -> { + String mode = PersonalizationManager.FIT_MODES[item.getItemId()]; + personalizationManager.setBackgroundFitMode(mode); + fit.setText(fitLabel(mode)); + personalizationManager.refreshBackgroundColorEffects(this); + return true; }); + menu.show(); + }); + + setupBackgroundImageControls(); + updateBgImageUI(); + setBackgroundImportState(LauncherBackgroundController.isImporting()); + } + + private int fitLabel(String mode) { + for (int i = 0; i < PersonalizationManager.FIT_MODES.length; i++) { + if (PersonalizationManager.FIT_MODES[i].equals(mode)) return FIT_LABELS[i]; } + return FIT_LABELS[0]; } private void setupBackgroundImageControls() { SeekBar blurSeek = findViewById(R.id.seek_bg_image_blur); SeekBar brightnessSeek = findViewById(R.id.seek_bg_image_brightness); + SeekBar opacitySeek = findViewById(R.id.seek_bg_image_opacity); bgImageBlurValue = findViewById(R.id.bg_image_blur_value); bgImageBrightnessValue = findViewById(R.id.bg_image_brightness_value); + bgImageOpacityValue = findViewById(R.id.bg_image_opacity_value); - if (blurSeek != null) { - blurSeek.setMax(PersonalizationManager.BG_BLUR_MAX); - blurSeek.setProgress(personalizationManager.getBackgroundImageBlur()); - updateBgImageBlurValue(blurSeek.getProgress()); - blurSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - updateBgImageBlurValue(progress); - if (!fromUser) return; - personalizationManager.setBackgroundImageBlur(progress); - if (personalizationManager.supportsRealtimeBackgroundBlur()) { - refreshBackgroundImageEffects(); - } + blurSeek.setMax(PersonalizationManager.BG_BLUR_MAX); + blurSeek.setProgress(personalizationManager.getBackgroundImageBlur()); + updateBgImageBlurValue(blurSeek.getProgress()); + blurSeek.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + updateBgImageBlurValue(progress); + if (!fromUser) return; + personalizationManager.setBackgroundImageBlur(progress); + if (personalizationManager.supportsRealtimeBackgroundBlur()) { + personalizationManager.refreshBackgroundEffects(SettingsActivity.this); } + updateMaterialStatus(); + } - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + personalizationManager.refreshBackgroundEffects(SettingsActivity.this); + onWallpaperEffectReleased(); + } + }); - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - refreshBackgroundImageEffects(); - } - }); - } + brightnessSeek.setMin(PersonalizationManager.BG_BRIGHTNESS_MIN); + brightnessSeek.setMax(PersonalizationManager.BG_BRIGHTNESS_MAX); + brightnessSeek.setProgress(personalizationManager.getBackgroundImageBrightness()); + setBgValue(bgImageBrightnessValue, R.string.settings_ui_value_percent, brightnessSeek.getProgress()); + brightnessSeek.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setBgValue(bgImageBrightnessValue, R.string.settings_ui_value_percent, progress); + if (!fromUser) return; + personalizationManager.setBackgroundImageBrightness(progress); + personalizationManager.refreshBackgroundColorEffects(SettingsActivity.this); + updateMaterialStatus(); + } - if (brightnessSeek != null) { - brightnessSeek.setMin(PersonalizationManager.BG_BRIGHTNESS_MIN); - brightnessSeek.setMax(PersonalizationManager.BG_BRIGHTNESS_MAX); - brightnessSeek.setProgress(personalizationManager.getBackgroundImageBrightness()); - updateBgImageBrightnessValue(brightnessSeek.getProgress()); - brightnessSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - updateBgImageBrightnessValue(progress); - if (!fromUser) return; - personalizationManager.setBackgroundImageBrightness(progress); - refreshBackgroundImageColorEffects(); - } + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + onWallpaperEffectReleased(); + } + }); - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } + opacitySeek.setMax(100); + opacitySeek.setProgress(personalizationManager.getBackgroundOpacity()); + setBgValue(bgImageOpacityValue, R.string.settings_ui_value_percent, opacitySeek.getProgress()); + opacitySeek.setOnSeekBarChangeListener(new SimpleSeekListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setBgValue(bgImageOpacityValue, R.string.settings_ui_value_percent, progress); + if (!fromUser) return; + personalizationManager.setBackgroundOpacity(progress); + personalizationManager.refreshBackgroundColorEffects(SettingsActivity.this); + updateMaterialStatus(); + } - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - } - }); - } + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + onWallpaperEffectReleased(); + } + }); } private void updateBgImageBlurValue(int blurRadius) { - if (bgImageBlurValue != null) { - bgImageBlurValue.setText(getString(R.string.bg_image_blur_value, blurRadius)); + setBgValue(bgImageBlurValue, R.string.settings_ui_value_px, blurRadius); + } + + private void setBgValue(TextView view, int formatRes, int value) { + if (view != null) view.setText(getString(formatRes, value)); + } + + private void updateBgImageUI() { + if (bgImageStatus == null) return; + boolean hasImage = personalizationManager.hasBackgroundImage(); + String folder = personalizationManager.getBackgroundFolder(); + + SeekBar blurSeek = findViewById(R.id.seek_bg_image_blur); + boolean canBlur = personalizationManager.canBlurBackground(); + blurSeek.setEnabled(canBlur); + if (canBlur) updateBgImageBlurValue(blurSeek.getProgress()); + else bgImageBlurValue.setText(R.string.bg_media_blur_unavailable); + + findViewById(R.id.bg_image_effect_controls).setVisibility(hasImage ? View.VISIBLE : View.GONE); + findViewById(R.id.bg_play_order_row).setVisibility(folder != null ? View.VISIBLE : View.GONE); + + if (folder != null) { + String path = folderPath(folder); + int slash = path.lastIndexOf('/'); + bgFolderName.setText(slash >= 0 && slash < path.length() - 1 ? path.substring(slash + 1) : path); + bgImageStatus.setText(path); + bgImageStatus.setVisibility(View.VISIBLE); + loadFolderCount(folder); + } else { + folderImageCount = -1; + bgImageCount.setVisibility(View.GONE); + bgFolderName.setText(hasImage ? R.string.settings_ui_single_image : R.string.settings_ui_no_folder); + bgImageStatus.setText(R.string.bg_image_selected); + bgImageStatus.setVisibility(hasImage ? View.VISIBLE : View.GONE); + } + + MaterialButton clear = findViewById(R.id.btn_clear_bg_image); + clear.setVisibility(hasImage || folder != null ? View.VISIBLE : View.GONE); + clear.setText(folder != null ? R.string.settings_ui_clear_folder : R.string.settings_ui_clear_image); + findViewById(R.id.btn_next_bg_image).setVisibility(folder != null ? View.VISIBLE : View.GONE); + updateNextButton(); + + loadThumbnail(hasImage); + View noImage = findViewById(R.id.material_no_image); + if (noImage != null) { + noImage.setVisibility(hasImage && personalizationManager.getBackgroundOpacity() > 0 + ? View.GONE : View.VISIBLE); + } + } + + /** "primary:Pictures/Wallpapers" style path of a document tree. */ + private static String folderPath(String treeUri) { + try { + String id = DocumentsContract.getTreeDocumentId(Uri.parse(treeUri)); + int colon = id.indexOf(':'); + String path = colon >= 0 ? id.substring(colon + 1) : id; + return path.isEmpty() ? id : path; + } catch (Exception e) { + return treeUri; } } - private void updateBgImageBrightnessValue(int brightnessPercent) { - if (bgImageBrightnessValue != null) { - bgImageBrightnessValue.setText(getString(R.string.bg_image_brightness_value, brightnessPercent)); + private void loadFolderCount(String folder) { + Context app = getApplicationContext(); + WeakReference ref = new WeakReference<>(this); + new Thread(() -> { + int count = WallpaperFolder.count(app, folder); + handler.post(() -> { + SettingsActivity activity = ref.get(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; + if (!folder.equals(activity.personalizationManager.getBackgroundFolder())) return; + activity.folderImageCount = count; + activity.bgImageCount.setText(activity.getString(R.string.settings_ui_image_count, count)); + activity.bgImageCount.setVisibility(View.VISIBLE); + activity.updateNextButton(); + }); + }, "WallpaperFolderCount").start(); + } + + private void loadThumbnail(boolean hasImage) { + int request = ++thumbnailRequest; + if (!hasImage) { + showThumbnailPlaceholder(); + return; } + PersonalizationManager pm = personalizationManager; + int size = dp(48) * 2; + WeakReference ref = new WeakReference<>(this); + new Thread(() -> { + Bitmap full = pm.loadBackgroundBitmap(); + Bitmap thumb = null; + if (full != null) { + float scale = Math.min(1f, (float) size / Math.min(full.getWidth(), full.getHeight())); + thumb = Bitmap.createScaledBitmap(full, Math.max(1, Math.round(full.getWidth() * scale)), + Math.max(1, Math.round(full.getHeight() * scale)), true); + if (thumb != full) full.recycle(); + } + Bitmap result = thumb; + handler.post(() -> { + SettingsActivity activity = ref.get(); + if (activity == null || activity.isDestroyed() || request != activity.thumbnailRequest) return; + if (result == null) { + activity.showThumbnailPlaceholder(); + } else { + activity.bgImagePreview.setImageTintList(null); + activity.bgImagePreview.setScaleType(ImageView.ScaleType.CENTER_CROP); + activity.bgImagePreview.setImageBitmap(result); + } + }); + }, "WallpaperThumbnail").start(); + } + + private void showThumbnailPlaceholder() { + bgImagePreview.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + bgImagePreview.setImageResource(R.drawable.ic_ui_image); + bgImagePreview.setImageTintList(ColorStateList.valueOf(getColor(R.color.ui_muted))); } - private void refreshBackgroundImageEffects() { - personalizationManager.refreshBackgroundEffects(this); - if (bgImagePreview != null) { - personalizationManager.refreshBackgroundImageView(bgImagePreview); + private void updateNextButton() { + View next = findViewById(R.id.btn_next_bg_image); + next.setEnabled(folderImageCount >= 2 && !LauncherBackgroundController.isImporting() + && !LauncherBackgroundController.isMinecraftBlocked()); + } + + private void setBackgroundImportState(boolean importing) { + boolean blocked = LauncherBackgroundController.isMinecraftBlocked(); + findViewById(R.id.btn_select_bg_image).setEnabled(!importing && !blocked); + findViewById(R.id.btn_select_bg_folder).setEnabled(!importing && !blocked); + findViewById(R.id.btn_clear_bg_image).setEnabled(!importing); + findViewById(R.id.btn_next_bg_image).setEnabled(!importing && !blocked && folderImageCount >= 2); + if (importing && bgImageStatus != null) { + bgImageStatus.setText(R.string.bg_media_importing); + bgImageStatus.setVisibility(View.VISIBLE); } } - private void refreshBackgroundImageColorEffects() { - personalizationManager.refreshBackgroundColorEffects(this); - if (bgImagePreview != null) { - personalizationManager.applyBackgroundImageEffects(bgImagePreview); + /** The single-image picker replaces a wallpaper folder. */ + private void importSingleImage(Uri uri) { + setBackgroundImportState(true); + WeakReference ref = new WeakReference<>(this); + LauncherBackgroundController.importBackground(getApplicationContext(), uri, success -> { + SettingsActivity activity = ref.get(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; + activity.setBackgroundImportState(false); + if (success) { + String folder = activity.personalizationManager.getBackgroundFolder(); + activity.personalizationManager.setBackgroundFolder(null); + releaseFolderPermission(activity, folder); + activity.recreate(); + } else { + activity.updateBgImageUI(); + Toast.makeText(activity, R.string.bg_media_import_failed, Toast.LENGTH_LONG).show(); + } + }); + } + + private void onFolderPicked(Uri uri) { + if (uri == null) return; + try { + getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (SecurityException ignored) { } + String previous = personalizationManager.getBackgroundFolder(); + String picked = uri.toString(); + personalizationManager.setBackgroundFolder(picked); + setBackgroundImportState(true); + Context app = getApplicationContext(); + WeakReference ref = new WeakReference<>(this); + WallpaperFolder.next(app, ok -> { + SettingsActivity activity = ref.get(); + if (ok) { + if (previous != null && !previous.equals(picked)) releaseFolderPermission(app, previous); + } else { + new PersonalizationManager(app).setBackgroundFolder(previous); + if (!picked.equals(previous)) releaseFolderPermission(app, picked); + } + if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; + activity.setBackgroundImportState(false); + if (ok) { + activity.recreate(); + } else { + activity.updateBgImageUI(); + Toast.makeText(activity, R.string.settings_ui_folder_failed, Toast.LENGTH_LONG).show(); + } + }); } - private void updateBgImageUI() { - if (bgImageStatus == null) return; - boolean hasBackgroundImage = personalizationManager.hasBackgroundImage(); - SeekBar blurSeek = findViewById(R.id.seek_bg_image_blur); - boolean canBlur = personalizationManager.canBlurBackground(); - if (blurSeek != null) blurSeek.setEnabled(canBlur); - if (!canBlur && bgImageBlurValue != null) bgImageBlurValue.setText(R.string.bg_media_blur_unavailable); - View effectControls = findViewById(R.id.bg_image_effect_controls); - if (effectControls != null) { - effectControls.setVisibility(hasBackgroundImage ? View.VISIBLE : View.GONE); + private void showNextWallpaper() { + setBackgroundImportState(true); + WeakReference ref = new WeakReference<>(this); + WallpaperFolder.next(getApplicationContext(), ok -> { + SettingsActivity activity = ref.get(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; + activity.setBackgroundImportState(false); + if (ok) { + activity.recreate(); + } else { + activity.updateBgImageUI(); + Toast.makeText(activity, R.string.bg_media_import_failed, Toast.LENGTH_LONG).show(); + } + }); + } + + private static void releaseFolderPermission(Context context, String treeUri) { + if (treeUri == null) return; + try { + context.getContentResolver().releasePersistableUriPermission(Uri.parse(treeUri), + Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (Exception ignored) { } + } - if (hasBackgroundImage) { - bgImageStatus.setText(R.string.bg_image_selected); - if (bgImagePreview != null) { - if (personalizationManager.applyBackgroundImageToView(bgImagePreview)) { - bgImagePreview.setVisibility(View.VISIBLE); - } + // #endregion + + // #region Material + + private void setupMaterialSection() { + SegmentedControl modeTabs = findViewById(R.id.material_mode_tabs); + modeTabs.setEqualWidth(false); + modeTabs.setItems(getString(R.string.settings_ui_mode_light), getString(R.string.settings_ui_mode_dark)); + modeTabs.setSelectedIndex(materialDark ? 1 : 0, false); + modeTabs.setOnSegmentSelectedListener(index -> { + materialDark = index == 1; + refreshMaterialUI(); + }); + + SimpleSeekListener sliderListener = new SimpleSeekListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + updateMaterialValues(); + if (!fromUser || bindingMaterial) return; + saveMaterialFromControls(); + // The wallpaper overlay can follow the slider live; the surfaces update on release. + if (materialDark == isDark()) personalizationManager.refreshBackgroundEffects(SettingsActivity.this); + updateMaterialStatus(); } - View btnClear = findViewById(R.id.btn_clear_bg_image); - if (btnClear != null) btnClear.setVisibility(View.VISIBLE); - } else { - bgImageStatus.setText(R.string.bg_media_none); - if (bgImagePreview != null) { - bgImagePreview.setImageDrawable(null); - bgImagePreview.setVisibility(View.GONE); + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + onMaterialChanged(); } - View btnClear = findViewById(R.id.btn_clear_bg_image); - if (btnClear != null) btnClear.setVisibility(View.GONE); + }; + ((SeekBar) findViewById(R.id.seek_material_opacity)).setOnSeekBarChangeListener(sliderListener); + ((SeekBar) findViewById(R.id.seek_material_overlay)).setOnSeekBarChangeListener(sliderListener); + + SwitchMaterial readability = findViewById(R.id.switch_material_readability); + readability.setOnCheckedChangeListener((btn, checked) -> { + if (bindingMaterial) return; + saveMaterialFromControls(); + onMaterialChanged(); + }); + + findViewById(R.id.material_reset).setOnClickListener(v -> { + UiSettings.applyMaterialPreset(this, materialDark, UiSettings.MATERIAL_BALANCED); + onMaterialChanged(); + }); + + refreshMaterialUI(); + } + + private void saveMaterialFromControls() { + UiSettings.setMaterial(this, materialDark, + ((SeekBar) findViewById(R.id.seek_material_opacity)).getProgress(), + ((SeekBar) findViewById(R.id.seek_material_overlay)).getProgress(), + ((SwitchMaterial) findViewById(R.id.switch_material_readability)).isChecked()); + } + + /** + * Wallpaper brightness, blur and opacity feed readability protection, which sets the surface + * opacity of every page: rebuild this page and let the others rebuild on resume. + */ + private void onWallpaperEffectReleased() { + if (!personalizationManager.hasBackgroundImage()) return; + PersonalizationManager.bumpChange(); + recreate(); + } + + /** Material is only visible over a wallpaper; then the page is rebuilt with the new surfaces. */ + private void onMaterialChanged() { + if (materialDark == isDark() && personalizationManager.hasBackgroundImage()) recreate(); + else refreshMaterialUI(); + } + + private void refreshMaterialUI() { + bindingMaterial = true; + ((SeekBar) findViewById(R.id.seek_material_opacity)).setProgress(UiSettings.getSurfaceOpacity(this, materialDark)); + ((SeekBar) findViewById(R.id.seek_material_overlay)).setProgress(UiSettings.getOverlayOpacity(this, materialDark)); + ((SwitchMaterial) findViewById(R.id.switch_material_readability)) + .setChecked(UiSettings.getReadability(this, materialDark)); + bindingMaterial = false; + updateMaterialValues(); + + // A fresh control shows no selection for a custom (slider-edited) material, as on Windows. + FrameLayout holder = findViewById(R.id.material_preset_holder); + holder.removeAllViews(); + SegmentedControl presets = new SegmentedControl(this); + presets.addSegment(getString(R.string.settings_ui_material_balanced), 0); + presets.addSegment(getString(R.string.settings_ui_material_clear), 0); + presets.addSegment(getString(R.string.settings_ui_material_solid), 0); + String preset = UiSettings.getMaterialPreset(this, materialDark); + for (int i = 0; i < MATERIAL_PRESETS.length; i++) { + if (MATERIAL_PRESETS[i].equals(preset)) presets.setSelectedIndex(i, false); } + presets.setOnSegmentSelectedListener(index -> { + UiSettings.applyMaterialPreset(this, materialDark, MATERIAL_PRESETS[index]); + onMaterialChanged(); + }); + holder.addView(presets, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + updateMaterialStatus(); } - private void setBackgroundImportState(boolean importing) { - View select = findViewById(R.id.btn_select_bg_image); - View clear = findViewById(R.id.btn_clear_bg_image); - if (select != null) select.setEnabled(!importing && !LauncherBackgroundController.isMinecraftBlocked()); - if (clear != null) clear.setEnabled(!importing); - if (importing && bgImageStatus != null) bgImageStatus.setText(R.string.bg_media_importing); + private void updateMaterialValues() { + setValueText(R.id.material_opacity_value, R.string.settings_ui_value_percent, + ((SeekBar) findViewById(R.id.seek_material_opacity)).getProgress()); + setValueText(R.id.material_overlay_value, R.string.settings_ui_value_percent, + ((SeekBar) findViewById(R.id.seek_material_overlay)).getProgress()); + } + + private void updateMaterialStatus() { + TextView status = findViewById(R.id.material_status); + if (status == null) return; + PersonalizationManager pm = personalizationManager; + int surface = UiSettings.getSurfaceOpacity(this, materialDark); + int effective = UiSettings.getEffectiveSurfaceOpacity(this, materialDark, + pm.getBackgroundImageBrightness(), pm.getBackgroundImageBlur(), pm.getBackgroundOpacity()); + boolean hasImage = pm.hasBackgroundImage() && pm.getBackgroundOpacity() > 0; + if (hasImage && effective > surface) { + status.setText(getString(R.string.settings_ui_material_protected_opacity, effective)); + } else { + status.setText(UiSettings.getReadability(this, materialDark) + ? R.string.settings_ui_material_protected : R.string.settings_ui_material_unprotected); + } } + // #endregion + @Override protected void onResume() { super.onResume(); if (personalizationManager != null) { updateBgImageUI(); setBackgroundImportState(LauncherBackgroundController.isImporting()); + updateMaterialStatus(); } if (pkgSettingsSection != null) { pkgSettingsSection.onResume(); @@ -779,6 +1376,7 @@ protected void onResume() { @Override protected void onDestroy() { + handler.removeCallbacksAndMessages(null); if (pkgSettingsSection != null) { pkgSettingsSection.onDestroy(); pkgSettingsSection = null; @@ -786,6 +1384,8 @@ protected void onDestroy() { super.onDestroy(); } + // #region Updates and about + private void setupUpdatesSection() { try { String localVersion = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; @@ -794,8 +1394,7 @@ private void setupUpdatesSection() { } catch (PackageManager.NameNotFoundException ignored) { } - Button btnCheckUpdate = findViewById(R.id.btn_check_update); - btnCheckUpdate.setOnClickListener(v -> handleUpdateButtonClick()); + findViewById(R.id.btn_check_update).setOnClickListener(v -> handleUpdateButtonClick()); preloaderSigsLastUpdateText = findViewById(R.id.preloader_sigs_last_update); preloaderSigsUpdateButton = findViewById(R.id.btn_update_preloader_sigs); @@ -884,9 +1483,15 @@ private void triggerEasterEgg() { } } - private void setupNavBar() { - setActiveNavTab(R.id.nav_tab_settings); - findViewById(R.id.nav_tab_settings).setOnClickListener(v -> {}); - } + // #endregion + private abstract static class SimpleSeekListener implements SeekBar.OnSeekBarChangeListener { + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + } } diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/AccountsAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/AccountsAdapter.java index 21907f932..d106a7762 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/AccountsAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/AccountsAdapter.java @@ -2,11 +2,9 @@ import android.content.Context; import android.content.res.ColorStateList; -import android.graphics.Color; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.ImageButton; import android.widget.TextView; @@ -14,8 +12,11 @@ import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.button.MaterialButton; + import org.levimc.launcher.R; import org.levimc.launcher.core.auth.MsftAccountStore; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.AccountTextUtils; import org.levimc.launcher.util.PersonalizationManager; @@ -62,12 +63,10 @@ public void onBindViewHolder(@NonNull AccountViewHolder holder, int position) { holder.activeBadge.setVisibility(View.VISIBLE); holder.btnUse.setEnabled(false); holder.btnUse.setText(R.string.ms_activated); - holder.btnUse.setAlpha(0.6f); } else { holder.activeBadge.setVisibility(View.GONE); holder.btnUse.setEnabled(true); holder.btnUse.setText(R.string.ms_set_active); - holder.btnUse.setAlpha(1.0f); } holder.btnUse.setOnClickListener(v -> { @@ -79,18 +78,23 @@ public void onBindViewHolder(@NonNull AccountViewHolder holder, int position) { new PersonalizationManager(holder.itemView.getContext()) .applyAccentToView(holder.itemView, holder.itemView.getContext()); - applyButtonAccent(holder); + applyAccent(holder, a.active); } - private void applyButtonAccent(@NonNull AccountViewHolder holder) { + private void applyAccent(@NonNull AccountViewHolder holder, boolean active) { Context context = holder.itemView.getContext(); - int accent = new PersonalizationManager(context).getAccentColor(); - if (accent == 0) { - accent = ContextCompat.getColor(context, R.color.primary); + UiKit.tintSoftAccent(holder.activeBadge, 7); + if (active) { + holder.itemView.setBackground(UiKit.selectedRing(context, 16)); + // Already-active account: quiet secondary look instead of a dimmed primary button. + holder.btnUse.setBackgroundTintList(ColorStateList.valueOf( + ContextCompat.getColor(context, R.color.ui_default))); + holder.btnUse.setTextColor(ContextCompat.getColor(context, R.color.text_secondary)); + } else { + holder.itemView.setBackgroundResource(R.drawable.bg_ui_card_inner); + new PersonalizationManager(context).applyGlassToView(holder.itemView); + UiKit.applyPrimary(holder.btnUse); } - ColorStateList tint = ColorStateList.valueOf(accent); - holder.btnUse.setBackgroundTintList(tint); - holder.btnUse.setTextColor(Color.WHITE); } @Override @@ -102,7 +106,7 @@ static class AccountViewHolder extends RecyclerView.ViewHolder { TextView title; TextView subtitle; TextView activeBadge; - Button btnUse; + MaterialButton btnUse; ImageButton btnDelete; public AccountViewHolder(@NonNull View itemView) { diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/CurseForgeContentAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/CurseForgeContentAdapter.java index e306076ec..0f54ff656 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/CurseForgeContentAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/CurseForgeContentAdapter.java @@ -4,7 +4,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; @@ -65,7 +64,7 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { if (holder instanceof ContentViewHolder) { - ((ContentViewHolder) holder).bind(contents.get(position), listener); + ((ContentViewHolder) holder).bind(contents.get(position), position == contents.size() - 1, listener); } else if (holder instanceof FooterViewHolder) { ((FooterViewHolder) holder).bind(currentPage, totalPages, pageChangeListener); } @@ -94,7 +93,11 @@ static class ContentViewHolder extends RecyclerView.ViewHolder { final TextView title; final TextView description; final TextView author; + final TextView categoryTag; + final TextView downloadsTag; final TextView metadata; + final View viewButton; + final View divider; ContentViewHolder(@NonNull View itemView) { super(itemView); @@ -102,45 +105,42 @@ static class ContentViewHolder extends RecyclerView.ViewHolder { title = itemView.findViewById(R.id.mod_title); description = itemView.findViewById(R.id.mod_description); author = itemView.findViewById(R.id.mod_author); + categoryTag = itemView.findViewById(R.id.mod_tag_category); + downloadsTag = itemView.findViewById(R.id.mod_tag_downloads); metadata = itemView.findViewById(R.id.mod_metadata); - PersonalizationManager personalization = new PersonalizationManager(itemView.getContext()); - if (personalization.hasCustomAccent()) { - metadata.setTextColor(personalization.getAccentColor()); - } - personalization.applyGlassToView(itemView); + viewButton = itemView.findViewById(R.id.mod_view); + divider = itemView.findViewById(R.id.mod_divider); + new PersonalizationManager(itemView.getContext()).applyAccentToView(itemView, itemView.getContext()); } - void bind(Content content, OnContentClickListener listener) { + void bind(Content content, boolean last, OnContentClickListener listener) { Context context = itemView.getContext(); title.setText(content.name != null ? content.name : ""); description.setText(content.summary != null ? content.summary : ""); description.setVisibility(content.summary == null || content.summary.isEmpty() ? View.GONE : View.VISIBLE); if (content.authors != null && !content.authors.isEmpty() && content.authors.get(0) != null) { - author.setText(context.getString(R.string.mod_author_byline, content.authors.get(0).name)); + author.setText("| " + context.getString(R.string.pkg_by_author, content.authors.get(0).name)); author.setVisibility(View.VISIBLE); } else { author.setText(""); author.setVisibility(View.GONE); } - StringBuilder meta = new StringBuilder(); - double downloads = content.downloadCount; - if (downloads >= 1000000) { - meta.append(String.format(Locale.getDefault(), "%.1fM", downloads / 1000000.0)); - } else if (downloads >= 1000) { - meta.append(String.format(Locale.getDefault(), "%.1fK", downloads / 1000.0)); + if (content.categories != null && !content.categories.isEmpty() && content.categories.get(0) != null + && content.categories.get(0).name != null) { + categoryTag.setText(content.categories.get(0).name); + categoryTag.setVisibility(View.VISIBLE); } else { - meta.append((long) downloads); - } - meta.append(" downloads"); - if (content.categories != null && !content.categories.isEmpty() && content.categories.get(0) != null) { - meta.append(" · ").append(content.categories.get(0).name); + categoryTag.setVisibility(View.GONE); } + downloadsTag.setText(context.getString(R.string.catalog_cf_downloads, formatCount(content.downloadCount))); if (content.dateModified != null && content.dateModified.length() >= 10) { - meta.append(" · ").append(content.dateModified, 0, 10); + metadata.setText(content.dateModified.substring(0, 10)); + metadata.setVisibility(View.VISIBLE); + } else { + metadata.setVisibility(View.GONE); } - metadata.setText(meta.toString()); String url = content.logo != null ? content.logo.thumbnailUrl : null; if (url != null && !url.isEmpty()) { @@ -154,13 +154,25 @@ void bind(Content content, OnContentClickListener listener) { Glide.with(icon).clear(icon); icon.setImageResource(R.drawable.ic_minecraft_cube); } - itemView.setOnClickListener(v -> listener.onContentClick(content)); + divider.setVisibility(last ? View.GONE : View.VISIBLE); + View.OnClickListener open = v -> listener.onContentClick(content); + itemView.findViewById(R.id.cf_row).setOnClickListener(open); + viewButton.setOnClickListener(open); + } + + private static String formatCount(double count) { + if (count >= 1000000) { + return String.format(Locale.getDefault(), "%.1fM", count / 1000000.0); + } else if (count >= 1000) { + return String.format(Locale.getDefault(), "%.1fK", count / 1000.0); + } + return String.valueOf((long) count); } } static class FooterViewHolder extends RecyclerView.ViewHolder { - final Button btnPrev; - final Button btnNext; + final View btnPrev; + final View btnNext; final TextView tvPageInfo; FooterViewHolder(@NonNull View itemView) { @@ -168,12 +180,6 @@ static class FooterViewHolder extends RecyclerView.ViewHolder { btnPrev = itemView.findViewById(R.id.btn_prev_page); btnNext = itemView.findViewById(R.id.btn_next_page); tvPageInfo = itemView.findViewById(R.id.tv_page_info); - PersonalizationManager personalization = new PersonalizationManager(itemView.getContext()); - if (personalization.hasCustomAccent()) { - int accent = personalization.getAccentColor(); - btnPrev.setTextColor(accent); - btnNext.setTextColor(accent); - } } void bind(int page, int total, OnPageChangeListener listener) { diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/ExternalModsAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/ExternalModsAdapter.java index 2a9ad72b7..6128bffe6 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/ExternalModsAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/ExternalModsAdapter.java @@ -1,25 +1,31 @@ package org.levimc.launcher.ui.adapter; +import android.content.Context; import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; -import com.google.android.material.card.MaterialCardView; +import com.google.android.material.button.MaterialButton; import org.levimc.launcher.R; import org.levimc.launcher.core.modcatalog.GithubUrls; import org.levimc.launcher.core.modcatalog.ModCatalog; import org.levimc.launcher.core.mods.Mod; import org.levimc.launcher.ui.animation.DynamicAnim; +import org.levimc.launcher.ui.widget.UiKit; +import org.levimc.launcher.util.PersonalizationManager; import java.util.ArrayList; import java.util.HashMap; @@ -28,6 +34,12 @@ import java.util.Map; public final class ExternalModsAdapter extends RecyclerView.Adapter { + /** Icon chip colors, picked per mod so the placeholder icons are told apart. */ + private static final int[] CHIP_COLORS = {UiKit.ROSE, UiKit.GREEN, UiKit.BLUE, UiKit.PURPLE, + UiKit.ORANGE, UiKit.TEAL, UiKit.INDIGO, UiKit.AMBER}; + /** The ChipGroup's fixed children (versions + download type); catalog tags follow them. */ + private static final int FIXED_TAGS = 2; + private static final int MAX_CATALOG_TAGS = 4; public interface OnActionClickListener { void onClick(ModCatalog.CatalogMod mod, ModCatalog.CatalogRelease release); } @@ -86,15 +98,24 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { boolean isBusy = mod.id != null && mod.id.equals(busyModId); String installedVersion = installedVersion(mod); + Context ctx = holder.itemView.getContext(); + holder.divider.setVisibility(position == 0 ? View.GONE : View.VISIBLE); holder.name.setText(mod.name); - holder.author.setText(holder.itemView.getContext().getString(R.string.external_mods_by_author, mod.author)); + holder.author.setText(TextUtils.isEmpty(mod.author) ? "" + : "| " + ctx.getString(R.string.mods_external_author, mod.author)); + holder.version.setText(release == null || TextUtils.isEmpty(release.version) ? "" + : "· " + versionLabel(release.version)); holder.description.setText(mod.description); - holder.version.setText(release == null ? "" : holder.itemView.getContext().getString( - R.string.external_mods_version, release.version)); - holder.versions.setText(release == null ? "" : holder.itemView.getContext().getString( - R.string.external_mods_minecraft_versions, TextUtils.join(", ", release.minecraftVersions))); - holder.tags.setText(mod.tags == null ? "" : TextUtils.join(" • ", mod.tags)); - holder.tags.setVisibility(mod.tags == null || mod.tags.isEmpty() ? View.GONE : View.VISIBLE); + holder.description.setVisibility(TextUtils.isEmpty(mod.description) ? View.GONE : View.VISIBLE); + + if (release == null || release.minecraftVersions == null || release.minecraftVersions.isEmpty()) { + holder.versions.setVisibility(View.GONE); + } else { + holder.versions.setVisibility(View.VISIBLE); + holder.versions.setText(ctx.getString(R.string.external_mods_minecraft_versions, + TextUtils.join(", ", release.minecraftVersions))); + UiKit.tintSoftAccent(holder.versions, 7); + } if (release == null) { holder.downloadType.setVisibility(View.GONE); @@ -102,32 +123,36 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { holder.downloadType.setVisibility(View.VISIBLE); if (release.isAdDownload()) { holder.downloadType.setText(R.string.external_mods_ad_download); - holder.downloadType.setTextColor(holder.itemView.getContext().getColor(R.color.external_mod_ad)); + holder.downloadType.setTextColor(ContextCompat.getColor(ctx, R.color.ui_warning)); } else if (release.opensInBrowser()) { holder.downloadType.setText(R.string.external_mods_external_link); - holder.downloadType.setTextColor(holder.itemView.getContext().getColor(R.color.tertiary)); + holder.downloadType.setTextColor(ContextCompat.getColor(ctx, R.color.text_secondary)); } else { holder.downloadType.setText(R.string.external_mods_direct_download); - holder.downloadType.setTextColor(holder.itemView.getContext().getColor(R.color.primary)); + holder.downloadType.setTextColor(ContextCompat.getColor(ctx, R.color.text_secondary)); } } + bindTags(holder, mod); - Object iconSource = TextUtils.isEmpty(mod.iconUrl) - ? R.drawable.ic_leaf_logo - : mod.iconUrl; + int chipColor = CHIP_COLORS[Math.floorMod(value(mod.id).hashCode(), CHIP_COLORS.length)]; + UiKit.tintIconChip(holder.iconChip, null, chipColor); + Drawable placeholder = placeholderIcon(ctx, chipColor); String proxiedIcon = TextUtils.isEmpty(mod.iconUrl) ? null : GithubUrls.proxied(githubProxy, mod.iconUrl); - if (proxiedIcon != null) { + if (TextUtils.isEmpty(mod.iconUrl)) { + Glide.with(holder.icon).clear(holder.icon); + holder.icon.setImageDrawable(placeholder); + } else if (proxiedIcon != null) { // Through the GitHub proxy, then directly. Glide.with(holder.icon) .load(proxiedIcon) - .placeholder(R.drawable.ic_leaf_logo) - .error(Glide.with(holder.icon).load(iconSource).error(R.drawable.ic_leaf_logo)) + .placeholder(placeholder) + .error(Glide.with(holder.icon).load(mod.iconUrl).error(placeholder)) .into(holder.icon); } else { Glide.with(holder.icon) - .load(iconSource) - .placeholder(R.drawable.ic_leaf_logo) - .error(R.drawable.ic_leaf_logo) + .load(mod.iconUrl) + .placeholder(placeholder) + .error(placeholder) .into(holder.icon); } @@ -148,11 +173,51 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { } holder.action.setEnabled(canInstall); - int buttonColor = holder.itemView.getContext().getColor(canInstall ? R.color.primary : R.color.outline); - holder.action.setBackgroundTintList(ColorStateList.valueOf(buttonColor)); + if (canInstall) { + UiKit.applyPrimary(holder.action); + } else { + // Disabled states use the secondary (grey) button look. + int label = ContextCompat.getColor(ctx, R.color.text_secondary); + holder.action.setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(ctx, R.color.ui_default))); + holder.action.setTextColor(label); + } holder.action.setOnClickListener(canInstall && listener != null ? v -> listener.onClick(mod, release) : null); DynamicAnim.applyPressScale(holder.action); + new PersonalizationManager(ctx).applyGlassToView(holder.itemView); + } + + /** Replaces the catalog tags after the fixed version / download-type tags. */ + private void bindTags(Holder holder, ModCatalog.CatalogMod mod) { + ViewGroup group = holder.tags; + while (group.getChildCount() > FIXED_TAGS) { + group.removeViewAt(group.getChildCount() - 1); + } + if (mod.tags == null) return; + Context ctx = group.getContext(); + int added = 0; + for (String tag : mod.tags) { + if (TextUtils.isEmpty(tag)) continue; + if (added++ >= MAX_CATALOG_TAGS) break; + TextView view = new TextView(ctx, null, 0, R.style.Widget_Levi_Tag); + view.setText(tag); + group.addView(view, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + } + + /** The puzzle icon in the chip color, inset so it sits in the middle of the 60dp chip. */ + private static Drawable placeholderIcon(Context ctx, int color) { + Drawable icon = AppCompatResources.getDrawable(ctx, R.drawable.ic_ui_puzzle); + if (icon == null) return null; + icon = icon.mutate(); + icon.setTint(color); + int inset = Math.round(UiKit.dp(ctx, 12)); + return new InsetDrawable(icon, inset); + } + + private static String versionLabel(String version) { + return version.startsWith("v") || version.startsWith("V") ? version : "v" + version; } @Override @@ -175,20 +240,25 @@ private String value(String value) { } static final class Holder extends RecyclerView.ViewHolder { - final MaterialCardView card; + final View card; + final View divider; + final View iconChip; final ImageView icon; final TextView name; final TextView author; final TextView description; final TextView version; final TextView versions; - final TextView tags; + final ViewGroup tags; final TextView downloadType; - final Button action; + final MaterialButton action; Holder(View itemView) { super(itemView); card = itemView.findViewById(R.id.external_mod_card); + divider = itemView.findViewById(R.id.external_mod_divider); + iconChip = itemView.findViewById(R.id.external_mod_icon_chip); + iconChip.setClipToOutline(true); icon = itemView.findViewById(R.id.external_mod_icon); name = itemView.findViewById(R.id.external_mod_name); author = itemView.findViewById(R.id.external_mod_author); diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/ModsAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/ModsAdapter.java index b79eaadd2..b0e7826e1 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/ModsAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/ModsAdapter.java @@ -3,7 +3,8 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Switch; +import android.widget.CompoundButton; +import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; @@ -30,6 +31,8 @@ public class ModsAdapter extends RecyclerView.Adapter private OnPkgUpdateClickListener onPkgUpdateClickListener; /** The packages owning mod folders (lower-case folder), from InstalledFragment.Model.byFolder. */ private Map pkgRows = Collections.emptyMap(); + /** False while the list is filtered or sorted: a reorder only makes sense on the full load order. */ + private boolean reorderEnabled = true; public interface OnModEnableChangeListener { @@ -74,6 +77,16 @@ public void setOnPkgUpdateClickListener(OnPkgUpdateClickListener listener) { this.onPkgUpdateClickListener = listener; } + public void setReorderEnabled(boolean enabled) { + if (reorderEnabled == enabled) return; + reorderEnabled = enabled; + notifyDataSetChanged(); + } + + public boolean isReorderEnabled() { + return reorderEnabled; + } + /** Shows the package badge and update chip of the folders packages own (loaded asynchronously). */ public void setPkgRows(Map byFolder) { this.pkgRows = byFolder == null ? Collections.emptyMap() : byFolder; @@ -94,13 +107,17 @@ public void onBindViewHolder(@NonNull ModViewHolder holder, int position) { holder.authorText.setText(holder.itemView.getContext().getString( R.string.mod_author_byline, fallbackText(mod.getAuthor(), holder.itemView.getContext().getString(R.string.mod_unknown_author)))); - holder.versionText.setText(holder.itemView.getContext().getString( - R.string.mod_version_chip, - fallbackText(mod.getVersion(), holder.itemView.getContext().getString(R.string.mod_unknown_version)))); + holder.versionText.setText( + fallbackText(mod.getVersion(), holder.itemView.getContext().getString(R.string.mod_unknown_version))); + + if (holder.divider != null) { + holder.divider.setVisibility(position == 0 ? View.GONE : View.VISIBLE); + } if (holder.orderText != null) { + // The position is the load order only while the full, unsorted list is shown. holder.orderText.setText(holder.itemView.getContext().getString(R.string.mod_load_order, position + 1)); - holder.orderText.setVisibility(View.VISIBLE); + holder.orderText.setVisibility(reorderEnabled ? View.VISIBLE : View.GONE); } if (holder.configBadge != null) { @@ -123,8 +140,10 @@ public void onBindViewHolder(@NonNull ModViewHolder holder, int position) { }); if (holder.dragHandle != null) { + holder.dragHandle.setVisibility(reorderEnabled ? View.VISIBLE : View.INVISIBLE); holder.dragHandle.setOnTouchListener((v, event) -> { - if (event.getAction() == android.view.MotionEvent.ACTION_DOWN && itemTouchHelper != null) { + if (event.getAction() == android.view.MotionEvent.ACTION_DOWN && itemTouchHelper != null + && reorderEnabled) { itemTouchHelper.startDrag(holder); } return false; @@ -134,8 +153,9 @@ public void onBindViewHolder(@NonNull ModViewHolder holder, int position) { ViewCompat.setTransitionName(holder.itemView, "mod_card_" + mod.getId()); holder.itemView.setOnClickListener(v -> { - if (onModClickListener != null) { - onModClickListener.onModClick(mod, position, holder.itemView); + int current = holder.getBindingAdapterPosition(); + if (onModClickListener != null && current != RecyclerView.NO_POSITION) { + onModClickListener.onModClick(mod, current, holder.itemView); } }); @@ -192,6 +212,10 @@ private void bindPackage(ModViewHolder holder, Mod mod) { InstalledFragment.Model.Row row = pkgRows.get(InstalledFragment.Model.folderKey(mod.getId())); if (holder.pkgBadge != null) { holder.pkgBadge.setVisibility(row != null ? View.VISIBLE : View.GONE); + if (row != null) { + holder.pkgBadge.setImageTintList(android.content.res.ColorStateList.valueOf( + org.levimc.launcher.ui.widget.UiKit.accentText(holder.itemView.getContext()))); + } } if (holder.pkgUpdate == null) { return; @@ -223,12 +247,13 @@ static class ModViewHolder extends RecyclerView.ViewHolder { TextView name; TextView orderText; TextView configBadge; - View pkgBadge; + ImageView pkgBadge; + View divider; TextView pkgUpdate; TextView authorText; TextView versionText; - Switch switchBtn; - android.widget.ImageView dragHandle; + CompoundButton switchBtn; + ImageView dragHandle; public ModViewHolder(@NonNull View itemView) { super(itemView); @@ -241,6 +266,7 @@ public ModViewHolder(@NonNull View itemView) { versionText = itemView.findViewById(R.id.mod_version); switchBtn = itemView.findViewById(R.id.mod_switch); dragHandle = itemView.findViewById(R.id.drag_handle); + divider = itemView.findViewById(R.id.mod_divider); } } } diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/QuickLaunchAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/QuickLaunchAdapter.java index 18d60c4b1..169037636 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/QuickLaunchAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/QuickLaunchAdapter.java @@ -1,14 +1,20 @@ package org.levimc.launcher.ui.adapter; +import android.content.Context; +import android.content.res.ColorStateList; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.button.MaterialButton; + import org.levimc.launcher.R; +import org.levimc.launcher.ui.widget.UiKit; import java.util.ArrayList; import java.util.List; @@ -69,7 +75,7 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { QuickLaunchItem item = items.get(position); - holder.bind(item); + holder.bind(item, position == items.size() - 1); } @Override @@ -77,25 +83,65 @@ public int getItemCount() { return items.size(); } + private static int iconFor(ActionType type) { + switch (type) { + case HOW_TO_PLAY: return R.drawable.ic_ui_lightbulb; + case SERVERS_TAB: return R.drawable.ic_ui_server; + case PROFILE_SCREEN: return R.drawable.ic_ui_person; + case STORE_HOME: return R.drawable.ic_ui_puzzle; + case MINECOIN_OFFERS: return R.drawable.ic_ui_bolt; + case MARKETPLACE_PASS: return R.drawable.ic_ui_star; + case CONNECT_SERVER: return R.drawable.ic_ui_globe; + case ADD_SERVER: return R.drawable.ic_ui_add; + case REALM_INVITE: return R.drawable.ic_ui_group; + case LOAD_WORLD: return R.drawable.ic_ui_image; + case SLASH_COMMAND: return R.drawable.ic_ui_code; + case CUSTOM_URI: + default: return R.drawable.ic_ui_open_in_new; + } + } + class ViewHolder extends RecyclerView.ViewHolder { + private final View row; + private final View iconChip; + private final ImageView iconView; private final TextView titleText; private final TextView descriptionText; + private final MaterialButton launchButton; + private final View divider; ViewHolder(@NonNull View itemView) { super(itemView); + row = itemView.findViewById(R.id.quick_launch_row); + iconChip = itemView.findViewById(R.id.icon_chip); + iconView = itemView.findViewById(R.id.icon_view); titleText = itemView.findViewById(R.id.title_text); descriptionText = itemView.findViewById(R.id.description_text); + launchButton = itemView.findViewById(R.id.launch_button); + divider = itemView.findViewById(R.id.item_divider); } - void bind(QuickLaunchItem item) { + void bind(QuickLaunchItem item, boolean last) { titleText.setText(item.title); descriptionText.setText(item.description); - - itemView.setOnClickListener(v -> { + iconView.setImageResource(iconFor(item.actionType)); + divider.setVisibility(last ? View.GONE : View.VISIBLE); + + Context ctx = itemView.getContext(); + int accent = UiKit.accent(ctx); + int accentText = UiKit.accentText(ctx); + UiKit.tintIconChip(iconChip, iconView, accent); + launchButton.setStrokeColor(ColorStateList.valueOf(accent)); + launchButton.setTextColor(accentText); + launchButton.setIconTint(ColorStateList.valueOf(accentText)); + + View.OnClickListener click = v -> { if (listener != null) { listener.onActionClick(item.actionType); } - }); + }; + row.setOnClickListener(click); + launchButton.setOnClickListener(click); } } } diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/ServersAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/ServersAdapter.java index e920e27f2..8a2fa0fad 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/ServersAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/ServersAdapter.java @@ -4,6 +4,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Button; +import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; @@ -11,6 +12,7 @@ import org.levimc.launcher.R; import org.levimc.launcher.core.content.ServerItem; +import org.levimc.launcher.ui.widget.UiKit; import java.util.List; @@ -45,6 +47,7 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) { ServerItem server = servers.get(position); holder.nameText.setText(server.name); holder.ipText.setText(server.ip + ":" + server.port); + UiKit.tintIconChip(holder.chip, holder.icon, UiKit.INDIGO); holder.deleteButton.setOnClickListener(v -> { if (listener != null) { @@ -66,12 +69,16 @@ static class ViewHolder extends RecyclerView.ViewHolder { TextView nameText; TextView ipText; Button deleteButton; + View chip; + ImageView icon; ViewHolder(View view) { super(view); nameText = view.findViewById(R.id.server_name); ipText = view.findViewById(R.id.server_ip); deleteButton = view.findViewById(R.id.server_delete_button); + chip = view.findViewById(R.id.server_chip); + icon = view.findViewById(R.id.server_icon); } } } diff --git a/app/src/main/java/org/levimc/launcher/ui/adapter/WorldsAdapter.java b/app/src/main/java/org/levimc/launcher/ui/adapter/WorldsAdapter.java index fbae749f6..e5bab61f6 100644 --- a/app/src/main/java/org/levimc/launcher/ui/adapter/WorldsAdapter.java +++ b/app/src/main/java/org/levimc/launcher/ui/adapter/WorldsAdapter.java @@ -146,7 +146,7 @@ public void onBindViewHolder(@NonNull WorldViewHolder holder, int position) { bound.worldDescription.setText(bound.itemView.getContext().getString(R.string.world_meta, world.getGameMode(), world.getFormattedSize())); } }); - holder.worldLastPlayed.setText(holder.itemView.getContext().getString(R.string.world_last_played, world.getFormattedLastModified())); + holder.worldLastPlayed.setText(world.getFormattedLastModified()); holder.itemView.setActivated(selected); holder.editButton.setVisibility(selectionMode ? View.GONE : View.VISIBLE); holder.overflowButton.setVisibility(selectionMode ? View.GONE : View.VISIBLE); diff --git a/app/src/main/java/org/levimc/launcher/ui/animation/DynamicAnim.java b/app/src/main/java/org/levimc/launcher/ui/animation/DynamicAnim.java index 5bb4266ae..9d4d8c96e 100644 --- a/app/src/main/java/org/levimc/launcher/ui/animation/DynamicAnim.java +++ b/app/src/main/java/org/levimc/launcher/ui/animation/DynamicAnim.java @@ -19,12 +19,23 @@ private DynamicAnim() {} private static final long DIALOG_DISMISS_DURATION_MS = 160L; + /** Settings > Personalization > Layout and motion > Disable animations (or the system setting). */ + private static boolean off() { + return org.levimc.launcher.util.UiSettings.animationsDisabled(); + } + + /** With animations disabled the spring starts at its target, so it ends on the first frame. */ + private static SpringAnimation settle(SpringAnimation anim, float target) { + if (off()) anim.setStartValue(target); + return anim; + } + public static SpringAnimation springAlphaTo(View view, float target) { SpringAnimation anim = new SpringAnimation(view, DynamicAnimation.ALPHA, target); anim.setSpring(new SpringForce(target) .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) .setStiffness(SpringForce.STIFFNESS_LOW)); - return anim; + return settle(anim, target); } public static SpringAnimation springTranslationYTo(View view, float target) { @@ -32,7 +43,7 @@ public static SpringAnimation springTranslationYTo(View view, float target) { anim.setSpring(new SpringForce(target) .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) .setStiffness(SpringForce.STIFFNESS_LOW)); - return anim; + return settle(anim, target); } public static SpringAnimation springTranslationXTo(View view, float target) { @@ -40,7 +51,7 @@ public static SpringAnimation springTranslationXTo(View view, float target) { anim.setSpring(new SpringForce(target) .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) .setStiffness(SpringForce.STIFFNESS_LOW)); - return anim; + return settle(anim, target); } public static SpringAnimation springScaleXTo(View view, float target) { @@ -48,7 +59,7 @@ public static SpringAnimation springScaleXTo(View view, float target) { anim.setSpring(new SpringForce(target) .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) .setStiffness(SpringForce.STIFFNESS_LOW)); - return anim; + return settle(anim, target); } public static SpringAnimation springScaleYTo(View view, float target) { @@ -56,14 +67,14 @@ public static SpringAnimation springScaleYTo(View view, float target) { anim.setSpring(new SpringForce(target) .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) .setStiffness(SpringForce.STIFFNESS_LOW)); - return anim; + return settle(anim, target); } /** * Apply press-scale feedback to a view. Does not consume touch, keeping click works. */ public static void applyPressScale(View view) { - if (view == null) return; + if (view == null || off()) return; view.setOnTouchListener((v, event) -> { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: { @@ -108,7 +119,7 @@ public static void animateDialogDismiss(View root, @Nullable Runnable onEnd) { .alpha(0f) .scaleX(0.94f) .scaleY(0.94f) - .setDuration(DIALOG_DISMISS_DURATION_MS) + .setDuration(off() ? 0L : DIALOG_DISMISS_DURATION_MS) .setInterpolator(new AccelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override @@ -133,7 +144,7 @@ public static void applyPressScaleRecursively(View root) { // 对 RecyclerView 可见子项做阶梯式入场(淡入 + 上滑) public static void staggerRecyclerChildren(RecyclerView rv) { - if (rv == null) return; + if (rv == null || off()) return; rv.post(() -> { int count = rv.getChildCount(); for (int i = 0; i < count; i++) { diff --git a/app/src/main/java/org/levimc/launcher/ui/packages/BrowseFragment.java b/app/src/main/java/org/levimc/launcher/ui/packages/BrowseFragment.java index 0a28d90a7..84487c8b4 100644 --- a/app/src/main/java/org/levimc/launcher/ui/packages/BrowseFragment.java +++ b/app/src/main/java/org/levimc/launcher/ui/packages/BrowseFragment.java @@ -17,15 +17,12 @@ import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.ScrollView; -import android.widget.Spinner; import android.widget.TextView; import androidx.activity.result.ActivityResultLauncher; @@ -34,7 +31,7 @@ import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; @@ -54,6 +51,8 @@ import org.levimc.launcher.core.packages.engine.Cancel; import org.levimc.launcher.core.versions.GameVersion; import org.levimc.launcher.ui.dialogs.CustomAlertDialog; +import org.levimc.launcher.ui.widget.CatalogSelect; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.util.PersonalizationManager; import org.levimc.launcher.util.UIHelper; @@ -75,9 +74,10 @@ public class BrowseFragment extends Fragment implements PackagesActivity.TargetL private EditText searchBox; private MaterialButton refreshButton; - private Spinner gameSpinner; - private Spinner loaderSpinner; - private Spinner sortSpinner; + private CatalogSelect gameSelect; + private CatalogSelect loaderSelect; + private CatalogSelect sortSelect; + private CatalogSelect orderSelect; private MaterialButton filtersButton; private View activeFiltersScroll; private ChipGroup activeFilters; @@ -99,7 +99,6 @@ public class BrowseFragment extends Fragment implements PackagesActivity.TargetL private int catalogRequest; private int contextRequest; private boolean resumedOnce; - private boolean binding; /** Opens a package page; its "More by owner" link comes back here as the owner to list. */ private final ActivityResultLauncher openPackage = registerForActivityResult( @@ -127,9 +126,10 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat MaterialButton searchButton = view.findViewById(R.id.pkg_btn_search); refreshButton = view.findViewById(R.id.pkg_btn_refresh); MaterialButton guideButton = view.findViewById(R.id.pkg_btn_guide); - gameSpinner = view.findViewById(R.id.pkg_spinner_game); - loaderSpinner = view.findViewById(R.id.pkg_spinner_ll); - sortSpinner = view.findViewById(R.id.pkg_spinner_sort); + gameSelect = new CatalogSelect(view.findViewById(R.id.pkg_spinner_game), getString(R.string.pkg_game_version_label)); + loaderSelect = new CatalogSelect(view.findViewById(R.id.pkg_spinner_ll), getString(R.string.pkg_ll_version_label)); + sortSelect = new CatalogSelect(view.findViewById(R.id.pkg_spinner_sort), getString(R.string.pkg_sort_by)); + orderSelect = new CatalogSelect(view.findViewById(R.id.pkg_spinner_order), getString(R.string.catalog_order)); filtersButton = view.findViewById(R.id.pkg_btn_filters); activeFiltersScroll = view.findViewById(R.id.pkg_active_filters_scroll); activeFilters = view.findViewById(R.id.pkg_active_filters); @@ -141,8 +141,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat emptyAction = view.findViewById(R.id.pkg_empty_action); loading = view.findViewById(R.id.pkg_loading); - int accent = PkgUi.accent(requireContext()); - searchButton.setBackgroundTintList(android.content.res.ColorStateList.valueOf(accent)); + UiKit.applyPrimary(searchButton); adapter = new BrowseFragment.Adapter(new Adapter.Listener() { @Override @@ -155,6 +154,11 @@ public void onOpenProject(CatalogModels.Pkg pkg) { BrowseFragment.this.onOpenProject(pkg); } + @Override + public void onInstall(CatalogModels.Pkg pkg) { + BrowseFragment.this.onInstallClick(pkg); + } + @Override public void onOpenAuthor(CatalogModels.Pkg pkg) { PkgUi.openUrl(requireContext(), pkg.ownerUrl()); @@ -165,15 +169,7 @@ public void onPage(int page) { BrowseFragment.this.onPage(page); } }, PackageService.rid()); - int span = getResources().getConfiguration().screenWidthDp >= 720 ? 2 : 1; - GridLayoutManager layout = new GridLayoutManager(requireContext(), span); - layout.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return adapter.getItemViewType(position) == BrowseFragment.Adapter.TYPE_FOOTER ? span : 1; - } - }); - results.setLayoutManager(layout); + results.setLayoutManager(new LinearLayoutManager(requireContext())); results.setItemAnimator(null); results.setAdapter(adapter); @@ -198,6 +194,7 @@ public int getSpanSize(int position) { PackagesActivity host = host(); if (host != null) { + host.bindHeader(view, 0); host.addPkgTargetListener(this); } loadCatalog(false); @@ -241,6 +238,10 @@ private GameVersion target() { @Override public void onPkgTargetChanged(@Nullable GameVersion target) { + PackagesActivity host = host(); + if (host != null && getView() != null) { + host.renderTargetTag(getView().findViewById(R.id.pkg_header_target)); + } loadFilterContext(); } @@ -255,13 +256,11 @@ private void runSearch() { } private void setupSortSpinner() { - String[] sortLabels = {getString(R.string.pkg_sort_relevance), getString(R.string.pkg_sort_stars), - getString(R.string.pkg_sort_updated), getString(R.string.pkg_sort_newest)}; - sortSpinner.setAdapter(spinnerAdapter(requireContext(), sortLabels)); - binding = true; - sortSpinner.setSelection(Math.max(0, SORT_KEYS.indexOf(filters.sort())), false); - binding = false; - sortSpinner.setOnItemSelectedListener(new SimpleSelection(position -> { + List sortLabels = java.util.Arrays.asList(getString(R.string.pkg_sort_relevance), + getString(R.string.pkg_sort_stars), getString(R.string.pkg_sort_updated), + getString(R.string.pkg_sort_newest)); + sortSelect.setItems(sortLabels, Math.max(0, SORT_KEYS.indexOf(filters.sort()))); + sortSelect.setOnPick(position -> { String key = SORT_KEYS.get(position); if (key.equals(filters.sort())) { return; @@ -269,11 +268,18 @@ private void setupSortSpinner() { filters.setSort(key); page = 1; render(); - })); + }); + orderSelect.setItems(java.util.Arrays.asList(getString(R.string.catalog_order_desc), + getString(R.string.catalog_order_asc)), filters.descending() ? 0 : 1); + orderSelect.setOnPick(position -> { + filters.setDescending(position == 0); + page = 1; + render(); + }); } private void setupVersionSpinners() { - gameSpinner.setOnItemSelectedListener(new SimpleSelection(position -> { + gameSelect.setOnPick(position -> { String game = position == 0 ? BrowseFragment.Filters.ALL : filters.gameOptions().get(position - 1); if (game.equals(filters.selectedGame())) { return; @@ -282,8 +288,8 @@ private void setupVersionSpinners() { page = 1; bindVersionSpinners(); render(); - })); - loaderSpinner.setOnItemSelectedListener(new SimpleSelection(position -> { + }); + loaderSelect.setOnPick(position -> { String ll = position == 0 ? BrowseFragment.Filters.ALL : filters.loaderOptions().get(position - 1); if (ll.equals(filters.selectedLoader())) { return; @@ -291,62 +297,29 @@ private void setupVersionSpinners() { filters.selectLoader(ll); page = 1; render(); - })); + }); bindVersionSpinners(); } private void bindVersionSpinners() { - if (gameSpinner == null) { + if (gameSelect == null) { return; } - Context c = requireContext(); List games = new ArrayList<>(); games.add(getString(R.string.pkg_game_all_versions)); games.addAll(filters.gameOptions()); List lls = new ArrayList<>(); lls.add(getString(R.string.pkg_ll_all_versions)); lls.addAll(filters.loaderOptions()); - binding = true; - gameSpinner.setAdapter(spinnerAdapter(c, games.toArray(new String[0]))); - loaderSpinner.setAdapter(spinnerAdapter(c, lls.toArray(new String[0]))); - gameSpinner.setSelection(filters.gameOptions().indexOf(filters.selectedGame()) + 1, false); - loaderSpinner.setSelection(filters.loaderOptions().indexOf(filters.selectedLoader()) + 1, false); - binding = false; + gameSelect.setItems(games, filters.gameOptions().indexOf(filters.selectedGame()) + 1); + loaderSelect.setItems(lls, filters.loaderOptions().indexOf(filters.selectedLoader()) + 1); // The game filter needs the catalogue's game versions; only the LeviLamina filter needs the database. - boolean hasGames = !filters.gameOptions().isEmpty(); - gameSpinner.setEnabled(hasGames); - gameSpinner.setAlpha(hasGames ? 1f : 0.5f); + gameSelect.setEnabled(!filters.gameOptions().isEmpty()); boolean mapping = filters.mappingAvailable(); - loaderSpinner.setEnabled(mapping); - loaderSpinner.setAlpha(mapping ? 1f : 0.5f); + loaderSelect.setEnabled(mapping); mappingHint.setVisibility(filters.contextReady() && !mapping ? View.VISIBLE : View.GONE); } - private static ArrayAdapter spinnerAdapter(Context c, String[] items) { - ArrayAdapter a = new ArrayAdapter<>(c, R.layout.spinner_item, items); - a.setDropDownViewResource(R.layout.spinner_dropdown_item); - return a; - } - - private final class SimpleSelection implements AdapterView.OnItemSelectedListener { - private final java.util.function.IntConsumer onSelect; - - SimpleSelection(java.util.function.IntConsumer onSelect) { - this.onSelect = onSelect; - } - - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (!binding) { - onSelect.accept(position); - } - } - - @Override - public void onNothingSelected(AdapterView parent) { - } - } - // #endregion // #region Loading @@ -469,6 +442,10 @@ private void render() { } result = Catalog.Search.run(packages, filters.query()); List sorted = result.matched; + if (!filters.descending()) { + sorted = new ArrayList<>(sorted); + Collections.reverse(sorted); + } int pages = BrowseFragment.Filters.pageCount(sorted.size()); page = BrowseFragment.Filters.clampPage(page, sorted.size()); List items = BrowseFragment.Filters.page(sorted, page); @@ -477,7 +454,7 @@ private void render() { results.setVisibility(showList ? View.VISIBLE : View.INVISIBLE); loading.setVisibility(loadingCatalog ? View.VISIBLE : View.GONE); int facets = filters.facetCount(); - filtersButton.setText(facets > 0 ? getString(R.string.pkg_filters_count, facets) : getString(R.string.pkg_filters)); + styleFiltersButton(facets); bindActiveFilters(); emptyAction.setVisibility(View.GONE); @@ -507,6 +484,17 @@ private void render() { } } + /** The Filters icon button: accent tinted while facet filters are on (the chips below list them). */ + private void styleFiltersButton(int facets) { + Context c = requireContext(); + filtersButton.setContentDescription(facets > 0 + ? getString(R.string.pkg_filters_count, facets) : getString(R.string.pkg_filters)); + int fill = facets > 0 ? UiKit.accentSoft(c) : c.getColor(R.color.ui_default); + int ink = facets > 0 ? UiKit.accentText(c) : c.getColor(R.color.text_primary); + filtersButton.setBackgroundTintList(ColorStateList.valueOf(fill)); + filtersButton.setIconTint(ColorStateList.valueOf(ink)); + } + /** The facet filters in effect as chips; tapping one removes it. */ private void bindActiveFilters() { activeFilters.removeAllViews(); @@ -577,6 +565,12 @@ public void onPackageClick(CatalogModels.Pkg pkg) { openPackage.launch(PackageActivity.intent(requireContext(), pkg.identifier, target())); } + /** The row's Install button: the package page on its Versions tab (pick a version, then install). */ + public void onInstallClick(CatalogModels.Pkg pkg) { + openPackage.launch(PackageActivity.intent(requireContext(), pkg.identifier, target()) + .putExtra(PackageActivity.EXTRA_SHOW_VERSIONS, true)); + } + public void onOpenProject(CatalogModels.Pkg pkg) { PkgUi.openUrl(requireContext(), pkg.projectUrl); } @@ -613,6 +607,7 @@ public final static class Filters { private String query = ""; private String sort = Catalog.Search.SORT_RELEVANCE; + private boolean descending = true; private Catalog.Search.Facet tags = Catalog.Search.Facet.EMPTY; private List license = new ArrayList<>(); private List dep = new ArrayList<>(); @@ -799,6 +794,15 @@ public void setSort(String key) { sort = Catalog.Search.SORTS.contains(key) ? key : Catalog.Search.SORT_RELEVANCE; } + /** Descending is the catalogue's order for the sort (most, latest or best match first); ascending reverses it. */ + public boolean descending() { + return descending; + } + + public void setDescending(boolean descending) { + this.descending = descending; + } + public Catalog.Search.Facet tags() { return tags; } @@ -912,6 +916,8 @@ public interface Listener { void onOpenProject(CatalogModels.Pkg pkg); + void onInstall(CatalogModels.Pkg pkg); + void onOpenAuthor(CatalogModels.Pkg pkg); void onPage(int page); @@ -965,7 +971,7 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { if (holder instanceof ItemHolder) { - ((ItemHolder) holder).bind(items.get(position)); + ((ItemHolder) holder).bind(items.get(position), position == items.size() - 1); } else if (holder instanceof FooterHolder) { ((FooterHolder) holder).bind(); } @@ -989,6 +995,8 @@ final class ItemHolder extends RecyclerView.ViewHolder { final TextView badge; final TextView meta; final ViewGroup tags; + final View install; + final View divider; ItemHolder(@NonNull View v) { super(v); @@ -1000,10 +1008,12 @@ final class ItemHolder extends RecyclerView.ViewHolder { badge = v.findViewById(R.id.pkg_pkg_badge); meta = v.findViewById(R.id.pkg_pkg_meta); tags = v.findViewById(R.id.pkg_pkg_tags); - new PersonalizationManager(v.getContext()).applyGlassToView(v); + install = v.findViewById(R.id.pkg_pkg_install); + divider = v.findViewById(R.id.pkg_pkg_divider); + new PersonalizationManager(v.getContext()).applyAccentToView(v, v.getContext()); } - void bind(CatalogModels.Pkg pkg) { + void bind(CatalogModels.Pkg pkg, boolean last) { Context ctx = itemView.getContext(); name.setText(pkg.name); boolean hasOwner = !pkg.author.trim().isEmpty(); @@ -1032,28 +1042,33 @@ void bind(CatalogModels.Pkg pkg) { boolean hasProject = Readme.Markdown.isOpenableExternal(pkg.projectUrl); open.setVisibility(hasProject ? View.VISIBLE : View.GONE); open.setOnClickListener(v -> listener.onOpenProject(pkg)); - itemView.setContentDescription(ctx.getString(R.string.pkg_view_details, pkg.name)); - itemView.setOnClickListener(v -> listener.onPackageClick(pkg)); + View row = itemView.findViewById(R.id.pkg_pkg_row); + row.setContentDescription(ctx.getString(R.string.pkg_view_details, pkg.name)); + row.setOnClickListener(v -> listener.onPackageClick(pkg)); + install.setOnClickListener(v -> listener.onInstall(pkg)); + divider.setVisibility(last ? View.GONE : View.VISIBLE); } } final class FooterHolder extends RecyclerView.ViewHolder { - final Button prev; - final Button next; + private static final int WINDOW = 5; + final View prev; + final View next; final TextView info; + final ViewGroup numbers; FooterHolder(@NonNull View v) { super(v); prev = v.findViewById(R.id.pkg_page_prev); next = v.findViewById(R.id.pkg_page_next); info = v.findViewById(R.id.pkg_page_info); - int accent = PkgUi.accent(v.getContext()); - prev.setTextColor(accent); - next.setTextColor(accent); + numbers = v.findViewById(R.id.pkg_page_numbers); } void bind() { - info.setText(itemView.getContext().getString(R.string.pkg_page_of, page, pages)); + Context ctx = itemView.getContext(); + info.setText(ctx.getString(R.string.pkg_page_of, page, pages)); + itemView.setContentDescription(info.getText()); boolean hasPrev = page > 1; boolean hasNext = page < pages; prev.setEnabled(hasPrev); @@ -1062,6 +1077,35 @@ void bind() { next.setAlpha(hasNext ? 1f : 0.4f); prev.setOnClickListener(v -> listener.onPage(page - 1)); next.setOnClickListener(v -> listener.onPage(page + 1)); + + numbers.removeAllViews(); + int first = Math.max(1, Math.min(page - WINDOW / 2, pages - WINDOW + 1)); + int lastPage = Math.min(pages, first + WINDOW - 1); + int size = Math.round(UiKit.dp(ctx, 36)); + int accent = UiKit.accent(ctx); + for (int p = first; p <= lastPage; p++) { + final int target = p; + TextView n = new TextView(ctx); + n.setText(String.valueOf(p)); + n.setGravity(Gravity.CENTER); + n.setTypeface(ResourcesCompat.getFont(ctx, R.font.misans), Typeface.BOLD); + n.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + if (p == page) { + android.graphics.drawable.GradientDrawable dot = new android.graphics.drawable.GradientDrawable(); + dot.setShape(android.graphics.drawable.GradientDrawable.OVAL); + dot.setColor(accent); + n.setBackground(dot); + n.setTextColor(org.levimc.launcher.util.ThemePalette.onAccent(accent)); + } else { + n.setBackgroundResource(R.drawable.bg_ui_row); + n.setTextColor(ctx.getColor(R.color.text_primary)); + n.setOnClickListener(v -> listener.onPage(target)); + } + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size); + lp.setMarginStart(Math.round(UiKit.dp(ctx, 2))); + lp.setMarginEnd(Math.round(UiKit.dp(ctx, 2))); + numbers.addView(n, lp); + } } } } diff --git a/app/src/main/java/org/levimc/launcher/ui/packages/InstalledFragment.java b/app/src/main/java/org/levimc/launcher/ui/packages/InstalledFragment.java index af2cb2ad1..26cc78ad6 100644 --- a/app/src/main/java/org/levimc/launcher/ui/packages/InstalledFragment.java +++ b/app/src/main/java/org/levimc/launcher/ui/packages/InstalledFragment.java @@ -4,7 +4,6 @@ import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Color; -import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -31,9 +30,12 @@ import com.bumptech.glide.Glide; import com.google.android.material.button.MaterialButton; +import com.google.android.material.divider.MaterialDividerItemDecoration; import com.google.android.material.switchmaterial.SwitchMaterial; import org.levimc.launcher.R; +import org.levimc.launcher.ui.widget.SegmentedControl; +import org.levimc.launcher.ui.widget.UiKit; import org.levimc.launcher.ui.dialogs.SimpleTextWatcher; import org.levimc.launcher.core.mods.Mod; import org.levimc.launcher.core.mods.ModManager; @@ -79,9 +81,7 @@ public class InstalledFragment extends Fragment implements PackagesActivity.Targ private MaterialButton refreshButton; private MaterialButton fileButton; private MaterialButton idButton; - private TextView filterAll; - private TextView filterExplicit; - private TextView filterDependency; + private SegmentedControl filterTabs; private SwitchMaterial onlyEnabledSwitch; private TextView summary; private View selectionBar; @@ -148,9 +148,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat refreshButton = view.findViewById(R.id.pkg_inst_btn_refresh); fileButton = view.findViewById(R.id.pkg_inst_btn_file); idButton = view.findViewById(R.id.pkg_inst_btn_id); - filterAll = view.findViewById(R.id.pkg_inst_filter_all); - filterExplicit = view.findViewById(R.id.pkg_inst_filter_explicit); - filterDependency = view.findViewById(R.id.pkg_inst_filter_dependency); + filterTabs = view.findViewById(R.id.pkg_inst_filter); onlyEnabledSwitch = view.findViewById(R.id.pkg_inst_only_enabled); summary = view.findViewById(R.id.pkg_inst_summary); selectionBar = view.findViewById(R.id.pkg_inst_selection_bar); @@ -168,10 +166,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat loading = view.findViewById(R.id.pkg_inst_loading); accent = PkgUi.accent(requireContext()); - idButton.setBackgroundTintList(android.content.res.ColorStateList.valueOf(accent)); - int onAccent = Color.luminance(accent) > 0.42f ? Color.BLACK : Color.WHITE; - idButton.setTextColor(onAccent); - idButton.setIconTint(android.content.res.ColorStateList.valueOf(onAccent)); + UiKit.applyPrimary(idButton); + UiKit.applyPrimary(selectionDone); progress.setShowCancel(true); progress.setShowStatus(true); @@ -202,6 +198,11 @@ public void onMore(Model.Row row, View anchor) { } }, accent); list.setLayoutManager(new LinearLayoutManager(requireContext())); + MaterialDividerItemDecoration divider = new MaterialDividerItemDecoration(requireContext(), + LinearLayoutManager.VERTICAL); + divider.setDividerColor(requireContext().getColor(R.color.divider)); + divider.setLastItemDecorated(false); + list.addItemDecoration(divider); list.setItemAnimator(null); list.setAdapter(adapter); @@ -220,9 +221,12 @@ public void onMore(Model.Row row, View anchor) { query = s.toString(); render(); })); - filterAll.setOnClickListener(v -> setFilter(InstalledFragment.Model.Filter.ALL)); - filterExplicit.setOnClickListener(v -> setFilter(InstalledFragment.Model.Filter.EXPLICIT)); - filterDependency.setOnClickListener(v -> setFilter(InstalledFragment.Model.Filter.DEPENDENCY)); + filterTabs.setEqualWidth(false); + filterTabs.setItems(getString(R.string.pkg_installed_filter_all), + getString(R.string.pkg_installed_filter_explicit), + getString(R.string.pkg_installed_filter_dependency)); + filterTabs.setOnSegmentSelectedListener(i -> setFilter(i == 1 ? InstalledFragment.Model.Filter.EXPLICIT + : i == 2 ? InstalledFragment.Model.Filter.DEPENDENCY : InstalledFragment.Model.Filter.ALL)); onlyEnabledSwitch.setOnCheckedChangeListener((b, checked) -> { onlyEnabled = checked; render(); @@ -237,13 +241,14 @@ public void onMore(Model.Row row, View anchor) { uninstallSelected.setOnClickListener(v -> runBatch(false)); selectionDone.setOnClickListener(v -> endSelection()); emptyAction.setOnClickListener(v -> load(true, false)); - for (View b : new View[]{refreshButton, fileButton, idButton, filterAll, filterExplicit, filterDependency, + for (View b : new View[]{refreshButton, fileButton, idButton, selectAll, updateSelected, uninstallSelected, selectionDone}) { DynamicAnim.applyPressScale(b); } PackagesActivity host = host(); if (host != null) { + host.bindHeader(view, 1); host.addPkgTargetListener(this); } TaskSession.addGlobalListener(this); @@ -299,6 +304,10 @@ private String targetNameOf(@Nullable GameVersion v) { @Override public void onPkgTargetChanged(@Nullable GameVersion target) { + PackagesActivity host = host(); + if (host != null && getView() != null) { + host.renderTargetTag(getView().findViewById(R.id.pkg_header_target)); + } targetName = targetNameOf(target); selecting = false; selected.clear(); @@ -460,28 +469,14 @@ private void setFilter(InstalledFragment.Model.Filter f) { render(); } - private void styleChip(TextView chip, boolean on) { - if (on) { - GradientDrawable gd = new GradientDrawable(); - gd.setColor(accent); - gd.setCornerRadius(15 * getResources().getDisplayMetrics().density); - chip.setBackground(gd); - chip.setTextColor(Color.luminance(accent) > 0.42f ? Color.BLACK : Color.WHITE); - } else { - chip.setBackgroundResource(R.drawable.bg_tab_unselected); - chip.setTextColor(requireContext().getColor(R.color.text_secondary)); - } - } - private void render() { if (getView() == null || adapter == null) { return; } Context c = requireContext(); GameVersion v = target(); - styleChip(filterAll, filter == InstalledFragment.Model.Filter.ALL); - styleChip(filterExplicit, filter == InstalledFragment.Model.Filter.EXPLICIT); - styleChip(filterDependency, filter == InstalledFragment.Model.Filter.DEPENDENCY); + filterTabs.setSelectedIndex(filter == InstalledFragment.Model.Filter.EXPLICIT ? 1 + : filter == InstalledFragment.Model.Filter.DEPENDENCY ? 2 : 0, false); fileButton.setEnabled(v != null); idButton.setEnabled(v != null); diff --git a/app/src/main/java/org/levimc/launcher/ui/packages/PackageActivity.java b/app/src/main/java/org/levimc/launcher/ui/packages/PackageActivity.java index 19e756da4..396bdccaf 100644 --- a/app/src/main/java/org/levimc/launcher/ui/packages/PackageActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/packages/PackageActivity.java @@ -68,6 +68,8 @@ public class PackageActivity extends BaseActivity { public static final String EXTRA_PACKAGE_ID = "package_id"; public static final String EXTRA_VERSION = "version"; + /** Opens on the Versions tab (the Browse list's Install button). */ + public static final String EXTRA_SHOW_VERSIONS = "show_versions"; /** Result extra: the owner the Browse tab that opened this page lists ("More by owner"). */ public static final String RESULT_AUTHOR = "author"; @@ -172,7 +174,7 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.pkg_pkg_btn_share).setOnClickListener(v -> share()); DynamicAnim.applyPressScale(install); - selectTab(false); + selectTab(getIntent().getBooleanExtra(EXTRA_SHOW_VERSIONS, false)); if (identifier.isEmpty()) { showError(getString(R.string.pkg_package_empty), false); return; @@ -435,18 +437,10 @@ private void selectTab(boolean versions) { } private void styleTab(TextView tab, boolean selected) { - if (selected) { - GradientDrawable gd = new GradientDrawable(); - gd.setColor(accent); - gd.setCornerRadius(16 * getResources().getDisplayMetrics().density); - tab.setBackground(gd); - tab.setTextColor(Color.luminance(accent) > 0.42f ? Color.BLACK : Color.WHITE); - tab.setTypeface(Typeface.DEFAULT_BOLD); - } else { - tab.setBackgroundResource(R.drawable.bg_tab_unselected); - tab.setTextColor(getColor(R.color.text_secondary)); - tab.setTypeface(Typeface.DEFAULT); - } + tab.setBackgroundResource(selected ? R.drawable.bg_ui_segment_selected : android.R.color.transparent); + tab.setElevation(selected ? getResources().getDisplayMetrics().density : 0); + tab.setTextColor(getColor(selected ? R.color.text_primary : R.color.text_secondary)); + tab.setTypeface(Typeface.create(tab.getTypeface(), selected ? Typeface.BOLD : Typeface.NORMAL)); } static String variantLabel(Context c, CatalogModels.Variant v) { diff --git a/app/src/main/java/org/levimc/launcher/ui/packages/PackagesActivity.java b/app/src/main/java/org/levimc/launcher/ui/packages/PackagesActivity.java index 5a99167ad..8ac3809ee 100644 --- a/app/src/main/java/org/levimc/launcher/ui/packages/PackagesActivity.java +++ b/app/src/main/java/org/levimc/launcher/ui/packages/PackagesActivity.java @@ -2,8 +2,6 @@ import android.content.Context; import android.content.Intent; -import android.graphics.Color; -import android.graphics.drawable.GradientDrawable; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -21,8 +19,7 @@ import org.levimc.launcher.core.versions.GameVersion; import org.levimc.launcher.core.versions.VersionManager; import org.levimc.launcher.ui.activities.BaseActivity; -import org.levimc.launcher.ui.animation.DynamicAnim; -import org.levimc.launcher.ui.dialogs.CustomAlertDialog; +import org.levimc.launcher.ui.widget.SegmentedControl; import org.levimc.launcher.util.PersonalizationManager; import java.io.File; @@ -30,23 +27,23 @@ import java.util.List; import java.util.Objects; +/** + * "LeviMC Mods" (Windows LIP page): the Browse and Installed tabs. Packages go into the global current instance + * (the one selected on the home page), as on Windows; each tab's header shows it as a read-only tag. + */ public class PackagesActivity extends BaseActivity { + /** Kept for callers; the page always follows the global current instance. */ public static final String EXTRA_VERSION = "version"; public static final String EXTRA_TAB = "tab"; public static final String TAB_BROWSE = "browse"; public static final String TAB_INSTALLED = "installed"; private static final String STATE_TAB = "pkg_tab"; - private static final String STATE_TARGET = "pkg_target"; - private TextView tabBrowse; - private TextView tabInstalled; - private TextView targetText; private String currentTab = TAB_BROWSE; @Nullable private GameVersion target; private final List listeners = new ArrayList<>(); - private int accent; public static Intent intent(Context context, @Nullable GameVersion version, @Nullable String tab) { Intent intent = new Intent(context, PackagesActivity.class); @@ -64,57 +61,33 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_packages); - PersonalizationManager pm = new PersonalizationManager(this); - accent = pm.getAccentColor(); - if (accent == 0) { - accent = getColor(R.color.primary); - } - - tabBrowse = findViewById(R.id.pkg_tab_browse); - tabInstalled = findViewById(R.id.pkg_tab_installed); - targetText = findViewById(R.id.pkg_target_text); - View targetChip = findViewById(R.id.pkg_target_chip); - + target = PackagesActivity.Instances.selected(this); if (savedInstanceState != null) { - target = savedInstanceState.getParcelable(STATE_TARGET); currentTab = savedInstanceState.getString(STATE_TAB, TAB_BROWSE); } else { - target = getIntent().getParcelableExtra(EXTRA_VERSION); - if (target == null) { - target = PackagesActivity.Instances.selected(this); - } String tab = getIntent().getStringExtra(EXTRA_TAB); currentTab = TAB_INSTALLED.equals(tab) ? TAB_INSTALLED : TAB_BROWSE; } - - tabBrowse.setOnClickListener(v -> selectTab(TAB_BROWSE)); - tabInstalled.setOnClickListener(v -> selectTab(TAB_INSTALLED)); - targetChip.setOnClickListener(v -> pickTarget()); - DynamicAnim.applyPressScale(targetChip); - DynamicAnim.applyPressScale(tabBrowse); - DynamicAnim.applyPressScale(tabInstalled); - - View root = findViewById(android.R.id.content); - if (root != null) { - pm.applyAccentToView(root, this); - } - renderTarget(); selectTab(currentTab); } + @Override + protected void onResume() { + super.onResume(); + // The current instance may have been switched on another page meanwhile. + setTarget(PackagesActivity.Instances.selected(this)); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putString(STATE_TAB, currentTab); - outState.putParcelable(STATE_TARGET, target); } // #region Tabs private void selectTab(String tab) { currentTab = tab; - styleTab(tabBrowse, TAB_BROWSE.equals(tab)); - styleTab(tabInstalled, TAB_INSTALLED.equals(tab)); FragmentManager fm = getSupportFragmentManager(); FragmentTransaction tx = fm.beginTransaction(); @@ -140,68 +113,62 @@ private void selectTab(String tab) { } } tx.commitNow(); + syncHeaderTabs(browse); + syncHeaderTabs(installed); } - private void styleTab(TextView tab, boolean selected) { - if (selected) { - GradientDrawable gd = new GradientDrawable(); - gd.setShape(GradientDrawable.RECTANGLE); - gd.setColor(accent); - gd.setCornerRadius(16 * getResources().getDisplayMetrics().density); - tab.setBackground(gd); - tab.setTextColor(Color.luminance(accent) > 0.42f ? Color.BLACK : Color.WHITE); - tab.setTypeface(tab.getTypeface(), android.graphics.Typeface.BOLD); - } else { - tab.setBackgroundResource(R.drawable.bg_tab_unselected); - tab.setTextColor(getColor(R.color.text_secondary)); - tab.setTypeface(android.graphics.Typeface.create(tab.getTypeface(), android.graphics.Typeface.NORMAL)); + /** Each tab draws its own copy of the header; keep the hidden one's segmented control in step. */ + private void syncHeaderTabs(@Nullable Fragment fragment) { + View root = fragment == null ? null : fragment.getView(); + SegmentedControl tabs = root == null ? null : root.findViewById(R.id.pkg_header_tabs); + if (tabs != null) { + tabs.setSelectedIndex(TAB_INSTALLED.equals(currentTab) ? 1 : 0, false); } } + /** + * Wires a tab's header (catalog_pkg_header_lead): the Browse / Installed segmented control and the current + * instance tag. {@code index} is the tab that owns the header (0 Browse, 1 Installed). + */ + void bindHeader(View root, int index) { + SegmentedControl tabs = root.findViewById(R.id.pkg_header_tabs); + if (tabs != null) { + tabs.setEqualWidth(false); + tabs.setItems(getString(R.string.pkg_tab_browse), getString(R.string.pkg_tab_installed)); + tabs.setSelectedIndex(index, false); + tabs.setOnSegmentSelectedListener(i -> selectTab(i == 1 ? TAB_INSTALLED : TAB_BROWSE)); + } + renderTargetTag(root.findViewById(R.id.pkg_header_target)); + // Tab fragments are added after setContentView's personalization pass: re-tint their accent views. + new PersonalizationManager(this).applyAccentToView(root, this); + // Fragment views are inflated after the page's glass pass; give their cards the wallpaper material too. + new PersonalizationManager(this).applyGlassToTree(root, this); + } + // #endregion // #region Target - private void renderTarget() { - if (target == null) { - targetText.setText(R.string.pkg_target_none); + void renderTargetTag(@Nullable TextView tag) { + if (tag == null) { return; } - String name = PackagesActivity.Instances.displayName(target); - String game = PackagesActivity.Instances.gameVersion(target); - targetText.setText(game.isEmpty() || game.equals(name) - ? name : getString(R.string.pkg_target_value, name, game)); - } - - private void pickTarget() { - List all = PackagesActivity.Instances.all(this); - if (all.isEmpty()) { - android.widget.Toast.makeText(this, R.string.not_found_version, android.widget.Toast.LENGTH_SHORT).show(); - return; - } - String[] names = new String[all.size()]; - for (int i = 0; i < all.size(); i++) { - GameVersion v = all.get(i); - String game = PackagesActivity.Instances.gameVersion(v); - String name = PackagesActivity.Instances.displayName(v); - names[i] = game.isEmpty() || game.equals(name) ? name : getString(R.string.pkg_target_value, name, game); - if (PackagesActivity.Instances.same(v, target)) { - names[i] = "✓ " + names[i]; - } + String value; + if (target == null) { + value = getString(R.string.pkg_target_none); + } else { + String name = PackagesActivity.Instances.displayName(target); + String game = PackagesActivity.Instances.gameVersion(target); + value = game.isEmpty() || game.equals(name) ? name : getString(R.string.pkg_target_value, name, game); } - new CustomAlertDialog(this) - .setTitleText(getString(R.string.pkg_target_select_title)) - .setItems(names, (dialog, which) -> setTarget(all.get(which))) - .setNegativeButton(getString(R.string.pkg_task_cancel), null) - .show(); + tag.setText(getString(R.string.catalog_instance_tag, value)); } - private void setTarget(GameVersion v) { + private void setTarget(@Nullable GameVersion v) { if (PackagesActivity.Instances.same(v, target)) { return; } target = v; - renderTarget(); for (TargetListener l : new ArrayList<>(listeners)) { l.onPkgTargetChanged(target); } diff --git a/app/src/main/java/org/levimc/launcher/ui/packages/SettingsCard.java b/app/src/main/java/org/levimc/launcher/ui/packages/SettingsCard.java index 62f3459d7..9c3e2fb63 100644 --- a/app/src/main/java/org/levimc/launcher/ui/packages/SettingsCard.java +++ b/app/src/main/java/org/levimc/launcher/ui/packages/SettingsCard.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; +import com.google.android.material.button.MaterialButton; import com.google.android.material.switchmaterial.SwitchMaterial; import org.levimc.launcher.R; @@ -28,6 +29,7 @@ import org.levimc.launcher.core.packages.PackageService; import org.levimc.launcher.core.packages.engine.Cancel; import org.levimc.launcher.settings.FeatureSettings; +import org.levimc.launcher.ui.widget.UiKit; import java.lang.ref.WeakReference; import java.text.DateFormat; @@ -110,14 +112,11 @@ public void onDestroy() { public void applyAccent() { int accent = PkgUi.accent(activity); - ColorStateList tint = ColorStateList.valueOf(accent); - cacheCleanButton.setBackgroundTintList(tint); - proxySave.setBackgroundTintList(tint); - catalogRefresh.setBackgroundTintList(tint); - int[][] states = {{android.R.attr.state_checked}, {}}; - scriptsSwitch.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent)); - scriptsSwitch.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); + // Clean cache and refresh are secondary buttons; only Save is the filled theme-color action. + if (proxySave instanceof MaterialButton) { + UiKit.applyPrimary((MaterialButton) proxySave); + } + new org.levimc.launcher.util.PersonalizationManager(activity).applyAccentToView(scriptsSwitch, activity); } // #region Cache diff --git a/app/src/main/java/org/levimc/launcher/ui/widget/CatalogSelect.java b/app/src/main/java/org/levimc/launcher/ui/widget/CatalogSelect.java new file mode 100644 index 000000000..06e59d089 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/ui/widget/CatalogSelect.java @@ -0,0 +1,173 @@ +package org.levimc.launcher.ui.widget; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.widget.NestedScrollView; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import org.levimc.launcher.R; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A Windows-style labelled select for touch: a {@code Widget.Levi.Input} trigger (value + chevron) that opens a + * bottom sheet listing the options, the current one highlighted. + */ +public final class CatalogSelect { + + public interface OnPick { + void onPick(int index); + } + + private final TextView trigger; + private CharSequence title; + private List items = Collections.emptyList(); + private int selected; + @Nullable + private OnPick listener; + + public CatalogSelect(TextView trigger, CharSequence title) { + this.trigger = trigger; + this.title = title; + trigger.setOnClickListener(v -> open()); + } + + public void setTitle(CharSequence title) { + this.title = title; + } + + public void setItems(List items, int selected) { + this.items = new ArrayList<>(items == null ? Collections.emptyList() : items); + setSelected(selected); + } + + public void setSelected(int index) { + selected = items.isEmpty() ? 0 : Math.max(0, Math.min(index, items.size() - 1)); + trigger.setText(items.isEmpty() ? "" : items.get(selected)); + } + + public int getSelected() { + return selected; + } + + public void setOnPick(@Nullable OnPick listener) { + this.listener = listener; + } + + public void setEnabled(boolean enabled) { + trigger.setEnabled(enabled); + trigger.setAlpha(enabled ? 1f : 0.5f); + } + + private void open() { + if (items.isEmpty() || !trigger.isEnabled()) { + return; + } + show(trigger.getContext(), title, items, selected, index -> { + if (index == selected) { + return; + } + setSelected(index); + if (listener != null) { + listener.onPick(index); + } + }); + } + + /** Shows a one-choice bottom sheet; {@code onPick} gets the tapped row. */ + public static void show(Context ctx, CharSequence title, List items, int selected, OnPick onPick) { + BottomSheetDialog dialog = new BottomSheetDialog(ctx); + Typeface font = ResourcesCompat.getFont(ctx, R.font.misans); + int accentText = UiKit.accentText(ctx); + int primaryText = UiKit.color(ctx, R.color.text_primary); + + LinearLayout root = new LinearLayout(ctx); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(UiKit.color(ctx, R.color.surface)); + root.setPadding(dp(ctx, 16), dp(ctx, 14), dp(ctx, 16), dp(ctx, 12)); + + TextView heading = new TextView(ctx); + heading.setText(title); + heading.setTextAppearance(R.style.TextAppearance_Levi_SectionTitle); + heading.setPadding(dp(ctx, 4), 0, dp(ctx, 4), dp(ctx, 8)); + root.addView(heading); + + NestedScrollView scroll = new NestedScrollView(ctx); + LinearLayout list = new LinearLayout(ctx); + list.setOrientation(LinearLayout.VERTICAL); + scroll.addView(list); + root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + for (int i = 0; i < items.size(); i++) { + final int index = i; + boolean isSelected = i == selected; + LinearLayout row = new LinearLayout(ctx); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(dp(ctx, 52)); + row.setPadding(dp(ctx, 14), 0, dp(ctx, 10), 0); + if (isSelected) { + UiKit.tintSoftAccent(row, 12); + } else { + row.setBackgroundResource(R.drawable.bg_ui_row); + } + + TextView label = new TextView(ctx); + label.setText(items.get(i)); + label.setTypeface(font, isSelected ? Typeface.BOLD : Typeface.NORMAL); + label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + label.setTextColor(isSelected ? accentText : primaryText); + label.setSingleLine(true); + label.setEllipsize(android.text.TextUtils.TruncateAt.END); + row.addView(label, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + if (isSelected) { + ImageView check = new ImageView(ctx); + check.setImageResource(R.drawable.ic_check); + check.setImageTintList(ColorStateList.valueOf(accentText)); + row.addView(check, new LinearLayout.LayoutParams(dp(ctx, 20), dp(ctx, 20))); + } + row.setClickable(true); + row.setFocusable(true); + row.setOnClickListener(v -> { + dialog.dismiss(); + onPick.onPick(index); + }); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + lp.bottomMargin = dp(ctx, 2); + list.addView(row, lp); + } + + dialog.setContentView(root); + dialog.setOnShowListener(d -> { + dialog.getBehavior().setState(BottomSheetBehavior.STATE_EXPANDED); + dialog.getBehavior().setSkipCollapsed(true); + if (selected > 0 && selected < list.getChildCount()) { + View target = list.getChildAt(selected); + scroll.post(() -> scroll.scrollTo(0, Math.max(0, target.getTop() - dp(ctx, 52)))); + } + }); + dialog.show(); + } + + private static int dp(Context ctx, float v) { + return Math.round(UiKit.dp(ctx, v)); + } +} diff --git a/app/src/main/java/org/levimc/launcher/ui/widget/SegmentedControl.java b/app/src/main/java/org/levimc/launcher/ui/widget/SegmentedControl.java new file mode 100644 index 000000000..c9925092d --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/ui/widget/SegmentedControl.java @@ -0,0 +1,163 @@ +package org.levimc.launcher.ui.widget; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.widget.TextViewCompat; + +import org.levimc.launcher.R; + +import java.util.ArrayList; +import java.util.List; + +/** + * Segmented tabs as in LeviLauncher for Windows (HeroUI Tabs): a grey track, the selected + * segment is a raised white (dark: zinc) pill. Each segment is at least 40dp tall for touch. + * + *
+ * control.setItems(getString(R.string.a), getString(R.string.b));
+ * control.setOnSegmentSelectedListener(index -> ...);
+ * control.setSelectedIndex(0, false);
+ * 
+ */ +public class SegmentedControl extends LinearLayout { + public interface OnSegmentSelectedListener { + void onSegmentSelected(int index); + } + + private final List segments = new ArrayList<>(); + private int selected = -1; + private boolean equalWidth = true; + private float textSizeSp = 14f; + @Nullable + private Typeface baseTypeface; + @Nullable + private OnSegmentSelectedListener listener; + + public SegmentedControl(Context context) { + this(context, null); + } + + public SegmentedControl(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + setBackgroundResource(R.drawable.bg_ui_segment_track); + int pad = dp(4); + setPadding(pad, pad, pad, pad); + setMinimumHeight(dp(48)); + setGravity(Gravity.CENTER_VERTICAL); + } + + /** True (default): segments share the width. False: each segment wraps its label (scrollable rows). */ + public void setEqualWidth(boolean equalWidth) { + this.equalWidth = equalWidth; + for (TextView t : segments) t.setLayoutParams(segmentParams()); + } + + public void setTextSizeSp(float sp) { + textSizeSp = sp; + for (TextView t : segments) t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp); + } + + public void setItems(CharSequence... labels) { + removeAllViews(); + segments.clear(); + for (int i = 0; i < labels.length; i++) addSegment(labels[i], 0); + if (labels.length > 0) setSelectedIndex(Math.max(0, Math.min(selected, labels.length - 1)), false); + } + + /** Adds a segment with an optional leading icon (0 for none). Returns its index. */ + public int addSegment(CharSequence label, int iconRes) { + TextView t = new TextView(getContext()); + t.setText(label); + t.setGravity(Gravity.CENTER); + t.setSingleLine(true); + t.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp); + if (baseTypeface == null) baseTypeface = ResourcesCompat.getFont(getContext(), R.font.misans); + t.setTypeface(baseTypeface); + t.setMinHeight(dp(40)); + t.setPadding(dp(14), 0, dp(14), 0); + if (iconRes != 0) { + Drawable d = ContextCompat.getDrawable(getContext(), iconRes); + if (d != null) { + d.setBounds(0, 0, dp(16), dp(16)); + t.setCompoundDrawablesRelative(d, null, null, null); + t.setCompoundDrawablePadding(dp(6)); + } + } + final int index = segments.size(); + t.setOnClickListener(v -> { + if (index != selected) setSelectedIndex(index, true); + }); + segments.add(t); + addView(t, segmentParams()); + applyState(); + return index; + } + + private LayoutParams segmentParams() { + return equalWidth + ? new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + : new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + } + + public void setOnSegmentSelectedListener(@Nullable OnSegmentSelectedListener listener) { + this.listener = listener; + } + + public int getSelectedIndex() { + return selected; + } + + public int getSegmentCount() { + return segments.size(); + } + + public TextView getSegment(int index) { + return segments.get(index); + } + + public void setSelectedIndex(int index, boolean notify) { + if (index < 0 || index >= segments.size()) return; + selected = index; + applyState(); + if (notify && listener != null) listener.onSegmentSelected(index); + } + + private void applyState() { + int active = ContextCompat.getColor(getContext(), R.color.text_primary); + int inactive = ContextCompat.getColor(getContext(), R.color.text_secondary); + for (int i = 0; i < segments.size(); i++) { + TextView t = segments.get(i); + boolean on = i == selected; + t.setBackgroundResource(on ? R.drawable.bg_ui_segment_selected : 0); + t.setElevation(on ? dp(1) : 0); + t.setTextColor(on ? active : inactive); + t.setTypeface(Typeface.create(baseTypeface, on ? Typeface.BOLD : Typeface.NORMAL)); + TextViewCompat.setCompoundDrawableTintList(t, ColorStateList.valueOf(on ? active : inactive)); + t.setSelected(on); + } + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + for (View v : segments) v.setEnabled(enabled); + setAlpha(enabled ? 1f : 0.5f); + } + + private int dp(float v) { + return Math.round(v * getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/org/levimc/launcher/ui/widget/ShellContentFrame.java b/app/src/main/java/org/levimc/launcher/ui/widget/ShellContentFrame.java new file mode 100644 index 000000000..b9ec195f7 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/ui/widget/ShellContentFrame.java @@ -0,0 +1,44 @@ +package org.levimc.launcher.ui.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import androidx.annotation.Nullable; + +/** + * Page area of the sidebar shell. Clips its children to a rounded top-left corner so the page meets + * the top bar and rail with the concave corner of the Windows launcher, whatever the page paints. + */ +public class ShellContentFrame extends FrameLayout { + private final Path clip = new Path(); + private final float radius; + + public ShellContentFrame(Context context) { + this(context, null); + } + + public ShellContentFrame(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + radius = 20f * getResources().getDisplayMetrics().density; + setWillNotDraw(false); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + clip.reset(); + clip.addRoundRect(new RectF(0, 0, w, h), new float[]{radius, radius, 0, 0, 0, 0, 0, 0}, Path.Direction.CW); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + int save = canvas.save(); + canvas.clipPath(clip); + super.dispatchDraw(canvas); + canvas.restoreToCount(save); + } +} diff --git a/app/src/main/java/org/levimc/launcher/ui/widget/UiKit.java b/app/src/main/java/org/levimc/launcher/ui/widget/UiKit.java new file mode 100644 index 000000000..b0e2eec5a --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/ui/widget/UiKit.java @@ -0,0 +1,124 @@ +package org.levimc.launcher.ui.widget; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import com.google.android.material.button.MaterialButton; + +import org.levimc.launcher.R; +import org.levimc.launcher.util.PersonalizationManager; +import org.levimc.launcher.util.ThemePalette; + +/** + * Helpers for the Windows-aligned look that need the chosen theme color at runtime + * (XML can only reference the default emerald). + */ +public final class UiKit { + private UiKit() { + } + + // Icon-chip palette of the Windows launcher (Tailwind 500 colors). + public static final int BLUE = 0xFF3B82F6; + public static final int PURPLE = 0xFFA855F7; + public static final int ORANGE = 0xFFF97316; + public static final int PINK = 0xFFEC4899; + public static final int INDIGO = 0xFF6366F1; + public static final int TEAL = 0xFF14B8A6; + public static final int AMBER = 0xFFF59E0B; + public static final int ROSE = 0xFFF43F5E; + public static final int GREEN = 0xFF22C55E; + public static final int RED = 0xFFEF4444; + + public static boolean isDark(Context ctx) { + return new PersonalizationManager(ctx).isDarkMode(ctx); + } + + public static int accent(Context ctx) { + return new PersonalizationManager(ctx).getAccentColor(isDark(ctx)); + } + + /** Page title color (Windows brand-heading). */ + public static int heading(Context ctx) { + return ThemePalette.heading(accent(ctx), isDark(ctx)); + } + + /** Accent text on surfaces (Windows brand-foreground). */ + public static int accentText(Context ctx) { + return ThemePalette.foreground(accent(ctx), isDark(ctx)); + } + + /** Soft accent fill (selected rows, accent tags): brand-500 at 10-15%. */ + public static int accentSoft(Context ctx) { + return ThemePalette.withAlpha(accent(ctx), isDark(ctx) ? 0.16f : 0.12f); + } + + public static void applyHeading(TextView title) { + title.setTextColor(heading(title.getContext())); + } + + /** Filled theme-color button. */ + public static void applyPrimary(MaterialButton button) { + int accent = accent(button.getContext()); + int label = ThemePalette.onAccent(accent); + button.setBackgroundTintList(ColorStateList.valueOf(accent)); + button.setTextColor(label); + button.setIconTint(ColorStateList.valueOf(label)); + } + + /** Soft accent pill or tag background, keeping the view's corner radius. */ + public static void tintSoftAccent(View view, float radiusDp) { + GradientDrawable gd = new GradientDrawable(); + gd.setCornerRadius(dp(view.getContext(), radiusDp)); + gd.setColor(accentSoft(view.getContext())); + view.setBackground(gd); + if (view instanceof TextView) ((TextView) view).setTextColor(accentText(view.getContext())); + } + + /** + * Colored square behind an icon (Windows icon chip): light = 500 at 12% on white, + * dark = 900 at 35%; the icon uses the 500 color. + */ + public static void tintIconChip(View chip, ImageView icon, int color) { + Context ctx = chip.getContext(); + boolean dark = isDark(ctx); + GradientDrawable gd = new GradientDrawable(); + gd.setCornerRadius(dp(ctx, 10)); + gd.setColor(dark ? ThemePalette.withAlpha(ThemePalette.shade(color, 900), 0.55f) + : ThemePalette.withAlpha(color, 0.12f)); + chip.setBackground(gd); + if (icon != null) icon.setImageTintList(ColorStateList.valueOf(color)); + } + + /** Selected-row / current-item highlight: soft accent fill with a 2dp accent ring. */ + public static Drawable selectedRing(Context ctx, float radiusDp) { + GradientDrawable gd = new GradientDrawable(); + gd.setCornerRadius(dp(ctx, radiusDp)); + gd.setColor(ThemePalette.withAlpha(accent(ctx), isDark(ctx) ? 0.12f : 0.07f)); + gd.setStroke(Math.round(dp(ctx, 2)), accent(ctx)); + return gd; + } + + public static int color(Context ctx, int res) { + return ContextCompat.getColor(ctx, res); + } + + public static int secondaryText(Context ctx) { + return ContextCompat.getColor(ctx, R.color.text_secondary); + } + + public static float dp(Context ctx, float v) { + return v * ctx.getResources().getDisplayMetrics().density; + } + + public static int alpha(int color, int alpha) { + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } +} diff --git a/app/src/main/java/org/levimc/launcher/util/LauncherBackgroundController.java b/app/src/main/java/org/levimc/launcher/util/LauncherBackgroundController.java index 33742c5d2..3426df80b 100644 --- a/app/src/main/java/org/levimc/launcher/util/LauncherBackgroundController.java +++ b/app/src/main/java/org/levimc/launcher/util/LauncherBackgroundController.java @@ -155,6 +155,9 @@ public static void importBackground(Context context, Uri uri, Consumer if (success && activity != null && !activity.isDestroyed()) { new PersonalizationManager(activity).applyToActivity(activity); resume(activity); + if (activity instanceof org.levimc.launcher.ui.activities.BaseActivity) { + ((org.levimc.launcher.ui.activities.BaseActivity) activity).markPersonalizationApplied(); + } } callback.accept(success); }); diff --git a/app/src/main/java/org/levimc/launcher/util/PersonalizationManager.java b/app/src/main/java/org/levimc/launcher/util/PersonalizationManager.java index 0cf239e57..f4ba5430f 100644 --- a/app/src/main/java/org/levimc/launcher/util/PersonalizationManager.java +++ b/app/src/main/java/org/levimc/launcher/util/PersonalizationManager.java @@ -48,12 +48,36 @@ public class PersonalizationManager { private static final String KEY_BG_POSTER_PATH = "bg_poster_path"; private static final String KEY_BG_IMAGE_BLUR = "bg_image_blur"; private static final String KEY_BG_IMAGE_BRIGHTNESS = "bg_image_brightness"; - + private static final String KEY_ACCENT_LIGHT = "accent_color_light"; + private static final String KEY_ACCENT_DARK = "accent_color_dark"; + private static final String KEY_BG_FIT_MODE = "bg_fit_mode"; + private static final String KEY_BG_OPACITY = "bg_opacity"; + private static final String KEY_BG_FOLDER = "bg_folder_uri"; + private static final String KEY_BG_PLAY_ORDER = "bg_play_order"; + private static final String KEY_BG_FOLDER_CURRENT = "bg_folder_current"; + + // Ranges shown by LeviLauncher for Windows (Settings > Personalization > Wallpaper). public static final int BG_BLUR_MIN = 0; - public static final int BG_BLUR_MAX = 25; - public static final int BG_BRIGHTNESS_MIN = 1; - public static final int BG_BRIGHTNESS_MAX = 150; + public static final int BG_BLUR_MAX = 50; + public static final int BG_BRIGHTNESS_MIN = 20; + public static final int BG_BRIGHTNESS_MAX = 100; public static final int BG_BRIGHTNESS_DEFAULT = 100; + public static final int BG_OPACITY_DEFAULT = 100; + + public static final String FIT_SMART = "smart"; + public static final String FIT_CENTER = "center"; + public static final String FIT_FIT = "fit"; + public static final String FIT_STRETCH = "stretch"; + public static final String FIT_TILE = "tile"; + public static final String FIT_TOP_LEFT = "top_left"; + public static final String FIT_TOP_RIGHT = "top_right"; + public static final String[] FIT_MODES = {FIT_SMART, FIT_CENTER, FIT_FIT, FIT_STRETCH, FIT_TILE, + FIT_TOP_LEFT, FIT_TOP_RIGHT}; + public static final String ORDER_RANDOM = "random"; + public static final String ORDER_SEQUENTIAL = "sequential"; + + /** View tag that keeps a view's own background when the wallpaper turns surfaces into glass. */ + public static final String TAG_KEEP_BACKGROUND = "levi_keep_background"; private static final AtomicInteger sChangeGeneration = new AtomicInteger(); private static final AtomicInteger sBackgroundEffectGeneration = new AtomicInteger(); @@ -61,15 +85,7 @@ public class PersonalizationManager { private final SharedPreferences prefs; private final Context context; - private static final int GLASS_ALPHA_DARK = 50; - private static final int GLASS_R_DARK = 25; - private static final int GLASS_G_DARK = 25; - private static final int GLASS_B_DARK = 25; - private static final int GLASS_ALPHA_LIGHT = 50; - private static final int GLASS_R_LIGHT = 255; - private static final int GLASS_G_LIGHT = 255; - private static final int GLASS_B_LIGHT = 255; public PersonalizationManager(Context context) { this.context = context.getApplicationContext(); @@ -90,24 +106,119 @@ public PersonalizationManager(Context context) { } } + /** Theme color of the mode in use. Light and dark modes keep their own color, as on Windows. */ public int getAccentColor() { - return prefs.getInt(KEY_ACCENT_COLOR, PRESET_COLORS[0]); + return getAccentColor(isDarkActive(context)); + } + + public int getAccentColor(boolean dark) { + String key = dark ? KEY_ACCENT_DARK : KEY_ACCENT_LIGHT; + if (prefs.contains(key)) return prefs.getInt(key, ThemePalette.DEFAULT_ACCENT); + // Before per-mode colors there was one color for both modes. + if (prefs.contains(KEY_ACCENT_COLOR)) return prefs.getInt(KEY_ACCENT_COLOR, ThemePalette.DEFAULT_ACCENT); + return ThemePalette.DEFAULT_ACCENT; } public void setAccentColor(int color) { - prefs.edit().putInt(KEY_ACCENT_COLOR, color).apply(); + setAccentColor(isDarkActive(context), color); + } + + public void setAccentColor(boolean dark, int color) { + prefs.edit().putInt(dark ? KEY_ACCENT_DARK : KEY_ACCENT_LIGHT, color | 0xFF000000).apply(); sChangeGeneration.incrementAndGet(); } public boolean hasCustomAccent() { - return prefs.contains(KEY_ACCENT_COLOR) && prefs.getInt(KEY_ACCENT_COLOR, 0) != 0; + return prefs.contains(KEY_ACCENT_LIGHT) || prefs.contains(KEY_ACCENT_DARK) + || (prefs.contains(KEY_ACCENT_COLOR) && prefs.getInt(KEY_ACCENT_COLOR, 0) != 0); } public void clearAccentColor() { - prefs.edit().remove(KEY_ACCENT_COLOR).apply(); + prefs.edit().remove(KEY_ACCENT_COLOR).remove(KEY_ACCENT_LIGHT).remove(KEY_ACCENT_DARK).apply(); sChangeGeneration.incrementAndGet(); } + /** Page title color for the current theme color (Windows brand-heading). */ + public int getHeadingColor(Context ctx) { + boolean dark = isDarkMode(ctx); + return ThemePalette.heading(getAccentColor(dark), dark); + } + + /** + * Dark mode as the launcher applies it: AppCompat's night mode, or the system setting when + * following the system. The application context does not carry AppCompat's night mode. + */ + public static boolean isDarkActive(Context context) { + int mode = androidx.appcompat.app.AppCompatDelegate.getDefaultNightMode(); + if (mode == androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES) return true; + if (mode == androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO) return false; + int night = context.getApplicationContext().getResources().getConfiguration().uiMode + & android.content.res.Configuration.UI_MODE_NIGHT_MASK; + return night == android.content.res.Configuration.UI_MODE_NIGHT_YES; + } + + public static void bumpChange() { + sChangeGeneration.incrementAndGet(); + } + + public static void bumpBackgroundEffects() { + sBackgroundEffectGeneration.incrementAndGet(); + } + + public String getBackgroundFitMode() { + String mode = prefs.getString(KEY_BG_FIT_MODE, FIT_SMART); + for (String m : FIT_MODES) if (m.equals(mode)) return m; + return FIT_SMART; + } + + public void setBackgroundFitMode(String mode) { + if (mode.equals(getBackgroundFitMode())) return; + prefs.edit().putString(KEY_BG_FIT_MODE, mode).apply(); + sBackgroundEffectGeneration.incrementAndGet(); + } + + public int getBackgroundOpacity() { + return clamp(prefs.getInt(KEY_BG_OPACITY, BG_OPACITY_DEFAULT), 0, 100); + } + + public void setBackgroundOpacity(int opacity) { + int clamped = clamp(opacity, 0, 100); + if (clamped == getBackgroundOpacity()) return; + prefs.edit().putInt(KEY_BG_OPACITY, clamped).apply(); + sBackgroundEffectGeneration.incrementAndGet(); + } + + /** Folder the wallpaper is picked from (a document tree URI), or null for a single image. */ + public String getBackgroundFolder() { + return prefs.getString(KEY_BG_FOLDER, null); + } + + public void setBackgroundFolder(String treeUri) { + SharedPreferences.Editor editor = prefs.edit(); + if (treeUri == null) editor.remove(KEY_BG_FOLDER).remove(KEY_BG_FOLDER_CURRENT); + else editor.putString(KEY_BG_FOLDER, treeUri); + editor.apply(); + } + + public String getBackgroundPlayOrder() { + return ORDER_SEQUENTIAL.equals(prefs.getString(KEY_BG_PLAY_ORDER, ORDER_RANDOM)) + ? ORDER_SEQUENTIAL : ORDER_RANDOM; + } + + public void setBackgroundPlayOrder(String order) { + prefs.edit().putString(KEY_BG_PLAY_ORDER, + ORDER_SEQUENTIAL.equals(order) ? ORDER_SEQUENTIAL : ORDER_RANDOM).apply(); + } + + /** Document id of the folder image shown now, used to pick the next one. */ + public String getBackgroundFolderCurrent() { + return prefs.getString(KEY_BG_FOLDER_CURRENT, null); + } + + public void setBackgroundFolderCurrent(String documentId) { + prefs.edit().putString(KEY_BG_FOLDER_CURRENT, documentId).apply(); + } + public String getBackgroundImagePath() { return prefs.getString(KEY_BG_IMAGE_PATH, null); } @@ -342,6 +453,20 @@ private void applyBackgroundImage(Activity activity, ViewGroup rootView) { } if (!applyBackgroundImageToView(bgView)) return; + boolean isDark = isDarkMode(activity); + View baseView = rootView.findViewWithTag("personalization_base"); + if (baseView == null) { + baseView = new View(activity); + baseView.setTag("personalization_base"); + rootView.addView(baseView, 0, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } else if (rootView.indexOfChild(baseView) > rootView.indexOfChild(background)) { + // The wallpaper view was re-created (image <-> animation); keep the base under it. + rootView.removeView(baseView); + rootView.addView(baseView, 0); + } + applyBaseLayer(baseView, isDark); + View overlayView = rootView.findViewWithTag("personalization_overlay"); if (overlayView == null) { overlayView = new View(activity); @@ -353,16 +478,40 @@ private void applyBackgroundImage(Activity activity, ViewGroup rootView) { rootView.addView(overlayView, bgIndex + 1); } - boolean isDark = isDarkMode(activity); - overlayView.setBackgroundColor(isDark ? Color.argb(100, 0, 0, 0) : Color.argb(80, 255, 255, 255)); - overlayView.setVisibility(View.VISIBLE); + applyOverlay(overlayView, isDark); makeChildBackgroundsTranslucent(rootView, activity); } + /** Solid layer under the wallpaper (Windows "Base mode"): none, the theme color, or a custom color. */ + private void applyBaseLayer(View baseView, boolean isDark) { + int page = ContextCompat.getColor(baseView.getContext(), R.color.background); + String mode = UiSettings.getBaseMode(context, isDark); + if (UiSettings.BASE_NONE.equals(mode)) { + baseView.setBackgroundColor(page); + return; + } + int color = UiSettings.BASE_THEME.equals(mode) + ? ThemePalette.shade(getAccentColor(isDark), isDark ? 900 : 50) + : UiSettings.getBaseColor(context, isDark); + baseView.setBackgroundColor(ThemePalette.mix(page, color, UiSettings.getBaseOpacity(context, isDark) / 100f)); + } + + /** Wallpaper scrim: the surface color at the material overlay opacity. */ + private void applyOverlay(View overlayView, boolean isDark) { + int alpha = Math.round(UiSettings.getOverlayOpacity(context, isDark) * 2.55f); + overlayView.setBackgroundColor(isDark ? Color.argb(alpha, 24, 24, 27) : Color.argb(alpha, 255, 255, 255)); + overlayView.setVisibility(View.VISIBLE); + } + public void refreshBackgroundEffects(Activity activity) { ViewGroup rootView = activity.findViewById(android.R.id.content); if (rootView == null) return; + boolean isDark = isDarkMode(activity); + View baseView = rootView.findViewWithTag("personalization_base"); + if (baseView != null) applyBaseLayer(baseView, isDark); + View overlayView = rootView.findViewWithTag("personalization_overlay"); + if (overlayView != null) applyOverlay(overlayView, isDark); ImageView bgView = findBackgroundPoster(rootView); if (bgView != null) refreshBackgroundImageView(bgView); LauncherBackgroundController.refreshEffects(); @@ -425,6 +574,8 @@ public void refreshBackgroundImageView(ImageView bgView) { } public void applyBackgroundImageEffects(ImageView bgView) { + applyFitMode(bgView); + bgView.setAlpha(getBackgroundOpacity() / 100f); int blurRadius = getBackgroundImageBlur(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (blurRadius > 0) { @@ -450,6 +601,48 @@ public void applyBackgroundImageEffects(ImageView bgView) { bgView.setColorFilter(new ColorMatrixColorFilter(matrix)); } + /** Windows fit modes: smart (cover), center, fit (contain), stretch, tile, top left, top right. */ + private void applyFitMode(ImageView bgView) { + String fit = getBackgroundFitMode(); + Drawable d = bgView.getDrawable(); + if (FIT_TILE.equals(fit) && d instanceof android.graphics.drawable.BitmapDrawable) { + android.graphics.drawable.BitmapDrawable tile = new android.graphics.drawable.BitmapDrawable( + bgView.getResources(), ((android.graphics.drawable.BitmapDrawable) d).getBitmap()); + tile.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); + bgView.setBackground(tile); + bgView.setImageAlpha(0); + return; + } + if (bgView.getBackground() instanceof android.graphics.drawable.BitmapDrawable) bgView.setBackground(null); + bgView.setImageAlpha(255); + switch (fit) { + case FIT_CENTER: + bgView.setScaleType(ImageView.ScaleType.CENTER); + break; + case FIT_FIT: + bgView.setScaleType(ImageView.ScaleType.FIT_CENTER); + break; + case FIT_STRETCH: + bgView.setScaleType(ImageView.ScaleType.FIT_XY); + break; + case FIT_TOP_LEFT: + case FIT_TOP_RIGHT: + bgView.setScaleType(ImageView.ScaleType.MATRIX); + bgView.post(() -> { + Drawable drawable = bgView.getDrawable(); + if (drawable == null) return; + android.graphics.Matrix m = new android.graphics.Matrix(); + if (FIT_TOP_RIGHT.equals(getBackgroundFitMode())) { + m.setTranslate(bgView.getWidth() - drawable.getIntrinsicWidth(), 0); + } + bgView.setImageMatrix(m); + }); + break; + default: + bgView.setScaleType(ImageView.ScaleType.CENTER_CROP); + } + } + private Bitmap createBlurredBitmap(Bitmap source, int radius) { if (source == null || radius <= 0) return source; try { @@ -530,12 +723,21 @@ private int clamp(int value, int min, int max) { return Math.max(min, Math.min(max, value)); } + /** Surface color over a wallpaper: Windows interface material (surface RGB at the effective opacity). */ private int getGlassColor(boolean isDark) { - if (isDark) { - return Color.argb(GLASS_ALPHA_DARK, GLASS_R_DARK, GLASS_G_DARK, GLASS_B_DARK); - } else { - return Color.argb(GLASS_ALPHA_LIGHT, GLASS_R_LIGHT, GLASS_G_LIGHT, GLASS_B_LIGHT); - } + // A fully transparent wallpaper counts as no wallpaper (Windows): solid surfaces. + if (getBackgroundOpacity() == 0) return isDark ? 0xFF18181B : 0xFFFFFFFF; + int opacity = UiSettings.getEffectiveSurfaceOpacity(context, isDark, getBackgroundImageBrightness(), + getBackgroundImageBlur(), getBackgroundOpacity()); + int alpha = Math.round(opacity * 2.55f); + return isDark ? Color.argb(alpha, 24, 24, 27) : Color.argb(alpha, 255, 255, 255); + } + + /** Chrome (navigation) color over a wallpaper: Windows NAVBAR_BG, the surface at 85%. */ + public int getChromeGlassColor(Context ctx) { + boolean isDark = isDarkMode(ctx); + int alpha = Math.max(Color.alpha(getGlassColor(isDark)), 217); + return isDark ? Color.argb(alpha, 24, 24, 27) : Color.argb(alpha, 255, 255, 255); } private void makeChildBackgroundsTranslucent(ViewGroup parent, Activity activity) { @@ -547,7 +749,12 @@ private void makeChildBackgroundsTranslucent(ViewGroup parent, Activity activity for (int i = 0; i < parent.getChildCount(); i++) { View child = parent.getChildAt(i); - if ("personalization_bg".equals(child.getTag()) || "personalization_overlay".equals(child.getTag())) { + if ("personalization_bg".equals(child.getTag()) || "personalization_overlay".equals(child.getTag()) + || "personalization_base".equals(child.getTag())) { + continue; + } + if (TAG_KEEP_BACKGROUND.equals(child.getTag())) { + if (child instanceof ViewGroup) makeChildBackgroundsTranslucent((ViewGroup) child, activity); continue; } @@ -578,7 +785,14 @@ private void makeChildBackgroundsTranslucent(ViewGroup parent, Activity activity if (gdColor == surfColor || gdColor == bgColor || gdColor == surfVariantColor || isNearBlack(gdColor) || isNearWhite(gdColor)) { GradientDrawable newGd = new GradientDrawable(); - newGd.setCornerRadius(dpToPx(activity, 12)); + float[] radii = null; + try { + radii = gd.getCornerRadii(); + } catch (Exception ignored) { + } + if (radii != null) newGd.setCornerRadii(radii); + else newGd.setCornerRadius(gd.getCornerRadius()); + newGd.setShape(gd.getShape()); newGd.setColor(glassColor); child.setBackground(newGd); } @@ -636,30 +850,21 @@ public void applyAccentColorRecursive(View view, int accentColor, Context ctx) { com.google.android.material.switchmaterial.SwitchMaterial sw = (com.google.android.material.switchmaterial.SwitchMaterial) view; try { - int[][] states = {{android.R.attr.state_checked}, {}}; - sw.setThumbTintList(new ColorStateList(states, new int[]{accentColor, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accentColor), Color.green(accentColor), Color.blue(accentColor)); - sw.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); + applySwitchColors(sw, accentColor, ctx); } catch (Exception ignored) {} } if (view instanceof Switch && !(view instanceof com.google.android.material.switchmaterial.SwitchMaterial)) { Switch sw = (Switch) view; try { - int[][] states = {{android.R.attr.state_checked}, {}}; - sw.setThumbTintList(new ColorStateList(states, new int[]{accentColor, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accentColor), Color.green(accentColor), Color.blue(accentColor)); - sw.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); + applySwitchColors(sw, accentColor, ctx); } catch (Exception ignored) {} } if (view instanceof androidx.appcompat.widget.SwitchCompat && !(view instanceof com.google.android.material.switchmaterial.SwitchMaterial)) { androidx.appcompat.widget.SwitchCompat sw = (androidx.appcompat.widget.SwitchCompat) view; try { - int[][] states = {{android.R.attr.state_checked}, {}}; - sw.setThumbTintList(new ColorStateList(states, new int[]{accentColor, 0xFFAAAAAA})); - int trackChecked = Color.argb(100, Color.red(accentColor), Color.green(accentColor), Color.blue(accentColor)); - sw.setTrackTintList(new ColorStateList(states, new int[]{trackChecked, 0xFF555555})); + applySwitchColors(sw, accentColor, ctx); } catch (Exception ignored) {} } @@ -686,14 +891,28 @@ public void applyAccentColorRecursive(View view, int accentColor, Context ctx) { if (btn.getBackgroundTintList() != null) { int currentTint = btn.getBackgroundTintList().getDefaultColor(); if (currentTint == defaultPrimary || currentTint == defaultSecondary || currentTint == defaultTertiary) { + int label = ThemePalette.onAccent(accentColor); btn.setBackgroundTintList(ColorStateList.valueOf(accentColor)); - btn.setTextColor(Color.WHITE); + btn.setTextColor(label); + if (btn instanceof com.google.android.material.button.MaterialButton) { + ((com.google.android.material.button.MaterialButton) btn) + .setIconTint(ColorStateList.valueOf(label)); + } } } } catch (Exception ignored) {} if (btn.getCurrentTextColor() == defaultPrimary || btn.getCurrentTextColor() == defaultAccentText) { - btn.setTextColor(accentColor); + int fg = ThemePalette.foreground(accentColor, isDarkMode(ctx)); + btn.setTextColor(fg); + if (btn instanceof com.google.android.material.button.MaterialButton) { + com.google.android.material.button.MaterialButton mb = + (com.google.android.material.button.MaterialButton) btn; + mb.setIconTint(ColorStateList.valueOf(fg)); + if (mb.getStrokeColor() != null && mb.getStrokeColor().getDefaultColor() == defaultPrimary) { + mb.setStrokeColor(ColorStateList.valueOf(accentColor)); + } + } } Drawable bg = btn.getBackground(); @@ -715,8 +934,14 @@ public void applyAccentColorRecursive(View view, int accentColor, Context ctx) { TextView tv = (TextView) view; int textColor = tv.getCurrentTextColor(); - if (textColor == defaultPrimary || textColor == defaultAccentText) { + if (textColor == defaultPrimary) { tv.setTextColor(accentColor); + } else if (textColor == defaultAccentText) { + // Accent text and page titles use the readable variant (Windows brand-foreground/heading). + boolean dark = isDarkMode(ctx); + float sp = tv.getTextSize() / ctx.getResources().getDisplayMetrics().scaledDensity; + tv.setTextColor(sp >= 20 ? ThemePalette.heading(accentColor, dark) + : ThemePalette.foreground(accentColor, dark)); } try { @@ -747,6 +972,32 @@ public void applyAccentColorRecursive(View view, int accentColor, Context ctx) { } } + /** Windows (HeroUI) switch: white thumb, theme-color track when on, grey track when off. */ + private void applySwitchColors(android.widget.CompoundButton sw, int accentColor, Context ctx) { + int[][] states = {{android.R.attr.state_checked}, {}}; + int off = isDarkMode(ctx) ? 0xFF52525B : 0xFFD4D4D8; + ColorStateList thumb = new ColorStateList(states, new int[]{0xFFFFFFFF, 0xFFFFFFFF}); + ColorStateList track = new ColorStateList(states, new int[]{accentColor, off}); + if (sw instanceof androidx.appcompat.widget.SwitchCompat) { + androidx.appcompat.widget.SwitchCompat c = (androidx.appcompat.widget.SwitchCompat) sw; + c.setThumbTintList(thumb); + c.setTrackTintList(track); + c.setTrackTintMode(android.graphics.PorterDuff.Mode.SRC_IN); + } else if (sw instanceof Switch) { + Switch c = (Switch) sw; + c.setThumbTintList(thumb); + c.setTrackTintList(track); + c.setTrackTintMode(android.graphics.PorterDuff.Mode.SRC_IN); + } + } + + /** Glass pass for views inflated after the page was set up (fragments, late content). */ + public void applyGlassToTree(View root, Activity activity) { + if (!hasBackgroundImage() || LauncherBackgroundController.isMinecraftBlocked()) return; + applyGlassToView(root); + if (root instanceof ViewGroup) makeChildBackgroundsTranslucent((ViewGroup) root, activity); + } + public void applyAccentToView(View view, Context context) { int accent = getAccentColor(); if (accent == 0) return; @@ -758,6 +1009,7 @@ private boolean isDarkMode(Activity activity) { } public boolean isDarkMode(Context ctx) { + if (!(ctx instanceof Activity)) return isDarkActive(ctx); int nightModeFlags = ctx.getResources().getConfiguration().uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK; return nightModeFlags == android.content.res.Configuration.UI_MODE_NIGHT_YES; @@ -822,27 +1074,7 @@ private float dpToPx(Context context, float dp) { return dp * context.getResources().getDisplayMetrics().density; } - public static final int[] PRESET_COLORS = { - 0xFF26A69A, 0xFF42A5F5, 0xFF5C6BC0, - 0xFFAB47BC, 0xFFEC407A, 0xFFEF5350, - 0xFFFF7043, 0xFFFFA726, 0xFFFFCA28, - 0xFF66BB6A, 0xFF26C6DA, 0xFF29B6F6, - 0xFF7E57C2, 0xFFE91E63, 0xFFF44336 - }; - - public static final int[] MORE_COLORS = { - 0xFFFF9800, 0xFFF57C00, 0xFFE65100, - 0xFFFF5722, 0xFFBF360C, 0xFFD32F2F, - 0xFFC62828, 0xFFAD1457, 0xFF880E4F, - 0xFFE91E63, 0xFFF06292, 0xFFCE93D8, - 0xFF9C27B0, 0xFF7B1FA2, 0xFF4A148C, - 0xFF5C6BC0, 0xFF3F51B5, 0xFF283593, - 0xFF1A237E, 0xFF1565C0, 0xFF0D47A1, - 0xFF0277BD, 0xFF00838F, 0xFF006064, - 0xFF00796B, 0xFF00695C, 0xFF004D40, - 0xFF2E7D32, 0xFF1B5E20, 0xFF33691E, - 0xFF827717, 0xFF9E9E9E, 0xFF757575, - 0xFF616161, 0xFF455A64, 0xFF37474F, - 0xFF4AE0A0, 0xFFCDDC39, 0xFF8BC34A - }; + public static final int[] PRESET_COLORS = ThemePalette.PRESETS; + + public static final int[] MORE_COLORS = ThemePalette.MORE; } diff --git a/app/src/main/java/org/levimc/launcher/util/ThemeManager.java b/app/src/main/java/org/levimc/launcher/util/ThemeManager.java index 7c9594f17..0f6ace031 100644 --- a/app/src/main/java/org/levimc/launcher/util/ThemeManager.java +++ b/app/src/main/java/org/levimc/launcher/util/ThemeManager.java @@ -1,17 +1,36 @@ package org.levimc.launcher.util; import android.app.Activity; +import android.content.Context; import android.content.SharedPreferences; import androidx.appcompat.app.AppCompatDelegate; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Appearance mode, matching LeviLauncher for Windows: light, dark, a manual dark-mode schedule, + * sunrise/sunset, or follow system. The stored integers of the original three modes are kept. + */ public class ThemeManager { public static final int MODE_FOLLOW_SYSTEM = 0; public static final int MODE_LIGHT = 1; public static final int MODE_DARK = 2; + public static final int MODE_SCHEDULE = 3; + public static final int MODE_SUN = 4; + + public static final String DEFAULT_SCHEDULE_START = "20:00"; + public static final String DEFAULT_SCHEDULE_END = "07:00"; private static final String THEME_PREFS = "theme_prefs"; private static final String THEME_MODE_KEY = "theme_mode"; + private static final String SCHEDULE_START_KEY = "schedule_start"; + private static final String SCHEDULE_END_KEY = "schedule_end"; + /** Same assumption as the Windows launcher: mid latitude, longitude from the time zone. */ + private static final double APPROX_LATITUDE = 35.0; + private static int sThemeChangeGeneration = 0; private final SharedPreferences prefs; private final Activity activity; @@ -37,8 +56,23 @@ public void setThemeMode(int mode) { } } + /** Sets the dark-mode window used by {@link #MODE_SCHEDULE} ("HH:mm"). */ + public void setSchedule(String start, String end) { + prefs.edit().putString(SCHEDULE_START_KEY, normalizeTime(start, DEFAULT_SCHEDULE_START)) + .putString(SCHEDULE_END_KEY, normalizeTime(end, DEFAULT_SCHEDULE_END)).apply(); + if (getCurrentMode() == MODE_SCHEDULE) refreshScheduledMode(); + } + + public String getScheduleStart() { + return normalizeTime(prefs.getString(SCHEDULE_START_KEY, DEFAULT_SCHEDULE_START), DEFAULT_SCHEDULE_START); + } + + public String getScheduleEnd() { + return normalizeTime(prefs.getString(SCHEDULE_END_KEY, DEFAULT_SCHEDULE_END), DEFAULT_SCHEDULE_END); + } + private void updateNightMode() { - int mode = prefs.getInt(THEME_MODE_KEY, MODE_FOLLOW_SYSTEM); + int mode = getCurrentMode(); switch (mode) { case MODE_LIGHT: AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); @@ -46,21 +80,122 @@ private void updateNightMode() { case MODE_DARK: AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); break; - default: // MODE_FOLLOW_SYSTEM + case MODE_SCHEDULE: + case MODE_SUN: + AppCompatDelegate.setDefaultNightMode(isScheduledDark() + ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO); + break; + default: AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); } } + /** + * For the time-based modes, re-applies the night mode when the clock crossed a boundary. + * Returns true when the night mode changed; the caller recreates the activity. + */ + public boolean refreshScheduledMode() { + int mode = getCurrentMode(); + if (mode != MODE_SCHEDULE && mode != MODE_SUN) return false; + int wanted = isScheduledDark() ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO; + if (AppCompatDelegate.getDefaultNightMode() == wanted) return false; + AppCompatDelegate.setDefaultNightMode(wanted); + sThemeChangeGeneration++; + return true; + } + + private boolean isScheduledDark() { + int now = minutesOfDay(Calendar.getInstance()); + if (getCurrentMode() == MODE_SUN) { + String[] sun = getSunTimes(); + return !inRange(now, parseMinutes(sun[0]), parseMinutes(sun[1])); + } + return inRange(now, parseMinutes(getScheduleStart()), parseMinutes(getScheduleEnd())); + } + + /** True when {@code now} is in [start, end), wrapping past midnight. */ + private static boolean inRange(int now, int start, int end) { + if (start == end) return false; + return start < end ? now >= start && now < end : now >= start || now < end; + } + public int getCurrentMode() { int mode = prefs.getInt(THEME_MODE_KEY, MODE_FOLLOW_SYSTEM); - if (mode < MODE_FOLLOW_SYSTEM || mode > MODE_DARK) { + if (mode < MODE_FOLLOW_SYSTEM || mode > MODE_SUN) { mode = MODE_FOLLOW_SYSTEM; prefs.edit().putInt(THEME_MODE_KEY, mode).apply(); } return mode; } + /** Quick toggle in the navigation, shown only for the manual light and dark modes (as on Windows). */ + public boolean canQuickToggle() { + int mode = getCurrentMode(); + return mode == MODE_LIGHT || mode == MODE_DARK; + } + public static int getThemeChangeGeneration() { return sThemeChangeGeneration; } -} \ No newline at end of file + + public static int getSavedMode(Context context) { + return context.getSharedPreferences(THEME_PREFS, Context.MODE_PRIVATE) + .getInt(THEME_MODE_KEY, MODE_FOLLOW_SYSTEM); + } + + /** Today's local sunrise and sunset as "HH:mm", computed like the Windows launcher. */ + public static String[] getSunTimes() { + Calendar now = Calendar.getInstance(); + TimeZone tz = now.getTimeZone(); + Calendar jan = Calendar.getInstance(tz); + jan.set(now.get(Calendar.YEAR), Calendar.JANUARY, 1, 12, 0, 0); + Calendar jul = Calendar.getInstance(tz); + jul.set(now.get(Calendar.YEAR), Calendar.JULY, 1, 12, 0, 0); + int standardOffsetMs = Math.min(tz.getOffset(jan.getTimeInMillis()), tz.getOffset(jul.getTimeInMillis())); + double lon = standardOffsetMs / 3600000.0 * 15.0; + + double n = now.get(Calendar.DAY_OF_YEAR); + double g = 2 * Math.PI / 365 * (n - 1); + double eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g) + - 0.014615 * Math.cos(2 * g) - 0.040849 * Math.sin(2 * g)); + double decl = 0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g) + - 0.006758 * Math.cos(2 * g) + 0.000907 * Math.sin(2 * g) + - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g); + double rad = Math.PI / 180; + double cosH = Math.cos(90.833 * rad) / (Math.cos(APPROX_LATITUDE * rad) * Math.cos(decl)) + - Math.tan(APPROX_LATITUDE * rad) * Math.tan(decl); + if (cosH > 1 || cosH < -1) return new String[]{"06:00", "18:00"}; + double h = Math.acos(cosH) / rad; + double riseUtc = 720 - 4 * (lon + h) - eqTime; + double setUtc = 720 - 4 * (lon - h) - eqTime; + int offsetMin = tz.getOffset(now.getTimeInMillis()) / 60000; + return new String[]{formatMinutes((int) Math.round(riseUtc) + offsetMin), + formatMinutes((int) Math.round(setUtc) + offsetMin)}; + } + + private static int minutesOfDay(Calendar c) { + return c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE); + } + + private static String formatMinutes(int minutes) { + int m = ((minutes % 1440) + 1440) % 1440; + return String.format(Locale.ROOT, "%02d:%02d", m / 60, m % 60); + } + + public static int parseMinutes(String time) { + try { + String[] parts = time.split(":"); + int h = Integer.parseInt(parts[0].trim()); + int m = Integer.parseInt(parts[1].trim()); + if (h < 0 || h > 23 || m < 0 || m > 59) return -1; + return h * 60 + m; + } catch (Exception e) { + return -1; + } + } + + public static String normalizeTime(String time, String fallback) { + int m = time == null ? -1 : parseMinutes(time); + return m < 0 ? fallback : formatMinutes(m); + } +} diff --git a/app/src/main/java/org/levimc/launcher/util/ThemePalette.java b/app/src/main/java/org/levimc/launcher/util/ThemePalette.java new file mode 100644 index 000000000..392852bd4 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/util/ThemePalette.java @@ -0,0 +1,116 @@ +package org.levimc.launcher.util; + +import android.graphics.Color; + +/** + * Theme colors shared with LeviLauncher for Windows (frontend/src/constants/themes.ts). + * PRESETS is the "Preset colors" group, MORE the "More colors" group; the default is emerald. + */ +public final class ThemePalette { + private ThemePalette() { + } + + public static final int DEFAULT_ACCENT = 0xFF10B981; + + /** Windows THEME_GROUPS.preset, in display order. */ + public static final int[] PRESETS = { + 0xFF10B981, // emerald + 0xFF3B82F6, // blue + 0xFF8B5CF6, // violet + 0xFFF59E0B, // amber + 0xFFFF6B8B, // pink + 0xFF06B6D4, // cyan + 0xFFFFB900, // Windows yellow + 0xFFFF8C00, // Windows orange + 0xFFD13438, // Windows red + 0xFF0078D7, // Windows blue + 0xFF10893E, // Windows green + 0xFF6B69D6, // Windows purple + 0xFF0099BC, // Windows teal + 0xFFE74856, // Windows pink red + }; + + /** Windows THEME_GROUPS.generated (Windows accent colors), in display order. */ + public static final int[] MORE = { + 0xFFF7630C, 0xFFCA5010, 0xFFDA3B01, 0xFFEF6950, 0xFFFF4343, 0xFFE81123, + 0xFFEA005E, 0xFFC30052, 0xFFE3008C, 0xFFBF0077, 0xFFC239B3, 0xFF9A0089, + 0xFF0063B1, 0xFF8E8CD8, 0xFF8764B8, 0xFF744DA9, 0xFFB146C2, 0xFF881798, + 0xFF2D7D9A, 0xFF00B7C3, 0xFF038387, 0xFF00B294, 0xFF018574, 0xFF00CC6A, + 0xFF7A7574, 0xFF5D5A58, 0xFF68768A, 0xFF515C6B, 0xFF567C73, 0xFF486860, + 0xFF498205, 0xFF107C10, 0xFF767676, 0xFF4C4A48, 0xFF69797E, + }; + + /** Mixes {@code color} toward {@code target} by {@code amount} (0..1), like CSS color-mix. */ + public static int mix(int color, int target, float amount) { + float a = Math.max(0f, Math.min(1f, amount)); + int r = Math.round(Color.red(color) + (Color.red(target) - Color.red(color)) * a); + int g = Math.round(Color.green(color) + (Color.green(target) - Color.green(color)) * a); + int b = Math.round(Color.blue(color) + (Color.blue(target) - Color.blue(color)) * a); + return Color.rgb(r, g, b); + } + + /** Windows generateTheme(): shade 50..950 of {@code base} (500 = base). */ + public static int shade(int base, int step) { + switch (step) { + case 50: return mix(base, Color.WHITE, 0.95f); + case 100: return mix(base, Color.WHITE, 0.90f); + case 200: return mix(base, Color.WHITE, 0.75f); + case 300: return mix(base, Color.WHITE, 0.50f); + case 400: return mix(base, Color.WHITE, 0.25f); + case 600: return mix(base, Color.BLACK, 0.15f); + case 700: return mix(base, Color.BLACK, 0.30f); + case 800: return mix(base, Color.BLACK, 0.50f); + case 900: return mix(base, Color.BLACK, 0.70f); + case 950: return mix(base, Color.BLACK, 0.85f); + default: return base | 0xFF000000; + } + } + + public static int withAlpha(int color, float alpha) { + return Color.argb(Math.round(Math.max(0f, Math.min(1f, alpha)) * 255), + Color.red(color), Color.green(color), Color.blue(color)); + } + + private static double channel(int c) { + double s = c / 255.0; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + } + + public static double luminance(int color) { + return 0.2126 * channel(Color.red(color)) + 0.7152 * channel(Color.green(color)) + + 0.0722 * channel(Color.blue(color)); + } + + public static double contrast(int a, int b) { + double la = luminance(a), lb = luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); + } + + /** + * Accent made readable on {@code surface}: darkened (light theme) or lightened (dark theme) + * until it reaches {@code ratio}, as Windows does for brand-foreground (4.6) and brand-heading (3.1). + */ + public static int readable(int accent, int surface, boolean dark, double ratio) { + int target = dark ? Color.WHITE : Color.BLACK; + int c = accent | 0xFF000000; + for (int i = 0; i <= 20 && contrast(c, surface) < ratio; i++) { + c = mix(accent, target, i * 0.05f); + } + return c; + } + + /** Heading color (page titles) for the accent: Windows brand-heading. */ + public static int heading(int accent, boolean dark) { + return readable(accent, dark ? 0xFF27272A : 0xFFF4F4F5, dark, 3.1); + } + + /** Text color for accent text on surfaces: Windows brand-foreground. */ + public static int foreground(int accent, boolean dark) { + return readable(accent, dark ? 0xFF27272A : 0xFFF4F4F5, dark, 4.6); + } + + /** Label color on a filled accent button. */ + public static int onAccent(int accent) { + return contrast(accent, 0xFFF4F4F5) >= 2.2 ? 0xFFF4F4F5 : 0xFF18181B; + } +} diff --git a/app/src/main/java/org/levimc/launcher/util/UiSettings.java b/app/src/main/java/org/levimc/launcher/util/UiSettings.java new file mode 100644 index 000000000..c629156b3 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/util/UiSettings.java @@ -0,0 +1,199 @@ +package org.levimc.launcher.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.provider.Settings; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Layout, motion and material settings of LeviLauncher for Windows + * (Settings > Personalization > Layout and motion / Materials and readability, and the base layer + * under the wallpaper). Material and base-layer values are stored per light and dark mode, as on Windows. + */ +public final class UiSettings { + private UiSettings() { + } + + public static final String LAYOUT_SIDEBAR = "sidebar"; + public static final String LAYOUT_NAVBAR = "navbar"; + + public static final String BASE_NONE = "none"; + public static final String BASE_THEME = "theme"; + public static final String BASE_COLOR = "color"; + + public static final String MATERIAL_BALANCED = "balanced"; + public static final String MATERIAL_CLEAR = "clear"; + public static final String MATERIAL_SOLID = "solid"; + public static final String MATERIAL_CUSTOM = "custom"; + + private static final String PREFS = "ui_prefs"; + private static final String KEY_LAYOUT_MODE = "layout_mode"; + private static final String KEY_DISABLE_ANIMATIONS = "disable_animations"; + + private static final AtomicInteger sLayoutGeneration = new AtomicInteger(); + private static volatile boolean sAnimationsDisabled; + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private static String mode(boolean dark) { + return dark ? "dark" : "light"; + } + + /** Bumped when a change needs open activities to rebuild their shell. */ + public static int getLayoutGeneration() { + return sLayoutGeneration.get(); + } + + // ---- Layout and motion ---- + + public static String getLayoutMode(Context context) { + String mode = prefs(context).getString(KEY_LAYOUT_MODE, LAYOUT_SIDEBAR); + return LAYOUT_NAVBAR.equals(mode) ? LAYOUT_NAVBAR : LAYOUT_SIDEBAR; + } + + public static boolean useTopNavbar(Context context) { + return LAYOUT_NAVBAR.equals(getLayoutMode(context)); + } + + public static void setLayoutMode(Context context, String mode) { + String value = LAYOUT_NAVBAR.equals(mode) ? LAYOUT_NAVBAR : LAYOUT_SIDEBAR; + if (value.equals(getLayoutMode(context))) return; + prefs(context).edit().putString(KEY_LAYOUT_MODE, value).apply(); + sLayoutGeneration.incrementAndGet(); + } + + public static boolean isAnimationsDisabledSetting(Context context) { + return prefs(context).getBoolean(KEY_DISABLE_ANIMATIONS, false); + } + + public static void setAnimationsDisabled(Context context, boolean disabled) { + prefs(context).edit().putBoolean(KEY_DISABLE_ANIMATIONS, disabled).apply(); + refreshAnimations(context); + } + + /** Re-reads the setting; the system "remove animations" option also disables them, as on Windows. */ + public static void refreshAnimations(Context context) { + float scale = 1f; + try { + scale = Settings.Global.getFloat(context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1f); + } catch (Exception ignored) { + } + sAnimationsDisabled = isAnimationsDisabledSetting(context) || scale == 0f; + } + + public static boolean animationsDisabled() { + return sAnimationsDisabled; + } + + // ---- Base layer under the wallpaper ---- + + public static String getBaseMode(Context context, boolean dark) { + String v = prefs(context).getString("base_mode_" + mode(dark), BASE_NONE); + return BASE_THEME.equals(v) || BASE_COLOR.equals(v) ? v : BASE_NONE; + } + + public static int getBaseColor(Context context, boolean dark) { + return prefs(context).getInt("base_color_" + mode(dark), dark ? 0xFF18181B : 0xFFFFFFFF); + } + + public static int getBaseOpacity(Context context, boolean dark) { + return clamp(prefs(context).getInt("base_opacity_" + mode(dark), 50), 0, 100); + } + + public static void setBase(Context context, boolean dark, String baseMode, int color, int opacity) { + prefs(context).edit() + .putString("base_mode_" + mode(dark), baseMode) + .putInt("base_color_" + mode(dark), color | 0xFF000000) + .putInt("base_opacity_" + mode(dark), clamp(opacity, 0, 100)) + .apply(); + PersonalizationManager.bumpBackgroundEffects(); + } + + // ---- Interface material (used while a wallpaper is set) ---- + + public static String getMaterialPreset(Context context, boolean dark) { + String v = prefs(context).getString("material_preset_" + mode(dark), MATERIAL_BALANCED); + switch (v) { + case MATERIAL_CLEAR: + case MATERIAL_SOLID: + case MATERIAL_CUSTOM: + return v; + default: + return MATERIAL_BALANCED; + } + } + + /** Interface opacity 0..100 (Windows surfaceOpacity). */ + public static int getSurfaceOpacity(Context context, boolean dark) { + return clamp(prefs(context).getInt("material_surface_" + mode(dark), + presetSurface(getMaterialPreset(context, dark))), 0, 100); + } + + /** Wallpaper overlay 0..80 (Windows overlayOpacity). */ + public static int getOverlayOpacity(Context context, boolean dark) { + return clamp(prefs(context).getInt("material_overlay_" + mode(dark), dark ? 18 : 8), 0, 80); + } + + public static boolean getReadability(Context context, boolean dark) { + return prefs(context).getBoolean("material_readability_" + mode(dark), + !MATERIAL_CLEAR.equals(getMaterialPreset(context, dark))); + } + + public static void applyMaterialPreset(Context context, boolean dark, String preset) { + prefs(context).edit() + .putString("material_preset_" + mode(dark), preset) + .putInt("material_surface_" + mode(dark), presetSurface(preset)) + .putInt("material_overlay_" + mode(dark), dark ? 18 : 8) + .putBoolean("material_readability_" + mode(dark), !MATERIAL_CLEAR.equals(preset)) + .apply(); + PersonalizationManager.bumpChange(); + } + + public static void setMaterial(Context context, boolean dark, int surfaceOpacity, int overlayOpacity, + boolean readability) { + prefs(context).edit() + .putString("material_preset_" + mode(dark), MATERIAL_CUSTOM) + .putInt("material_surface_" + mode(dark), clamp(surfaceOpacity, 0, 100)) + .putInt("material_overlay_" + mode(dark), clamp(overlayOpacity, 0, 80)) + .putBoolean("material_readability_" + mode(dark), readability) + .apply(); + PersonalizationManager.bumpChange(); + } + + private static int presetSurface(String preset) { + switch (preset) { + case MATERIAL_CLEAR: return 35; + case MATERIAL_SOLID: return 100; + default: return 60; + } + } + + /** + * Surface opacity actually used, with readability protection applied + * (Windows backgroundAppearance.ts: minimum opacity from mode, brightness, blur and image opacity). + */ + public static int getEffectiveSurfaceOpacity(Context context, boolean dark, int wallpaperBrightness, + int wallpaperBlur, int wallpaperOpacity) { + int surface = getSurfaceOpacity(context, dark); + if (!getReadability(context, dark)) return surface; + float overlay = getOverlayOpacity(context, dark) / 100f; + float imageOpacity = wallpaperOpacity / 100f; + // Android has no interface blur, so only the wallpaper blur counts toward the detail guard. + float guard = wallpaperBlur < 8 ? 0.06f : 0f; + float brightest = (Math.min(1f, wallpaperBrightness / 100f) * imageOpacity + 1f - imageOpacity) + * (1f - overlay); + float floor = dark + ? (brightest - (0.36f - guard)) / Math.max(0.01f, brightest - 27f / 255f) + : (0.68f + guard - overlay) / Math.max(0.01f, 1f - overlay); + floor = Math.min(1f, Math.max(0f, floor)); + return clamp(Math.max(surface, Math.round(floor * 100)), 0, 100); + } + + static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(max, v)); + } +} diff --git a/app/src/main/java/org/levimc/launcher/util/WallpaperFolder.java b/app/src/main/java/org/levimc/launcher/util/WallpaperFolder.java new file mode 100644 index 000000000..637f17dc5 --- /dev/null +++ b/app/src/main/java/org/levimc/launcher/util/WallpaperFolder.java @@ -0,0 +1,148 @@ +package org.levimc.launcher.util; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.DocumentsContract; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.function.Consumer; + +/** + * Wallpaper folder, as on LeviLauncher for Windows: the wallpaper is picked from a folder, a new one + * on every launch (random or sequential), and "Next image" switches right away. + */ +public final class WallpaperFolder { + private WallpaperFolder() { + } + + private static final String[] EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}; + private static boolean sRotatedThisLaunch; + + public static final class Image { + public final String documentId; + public final String name; + public final Uri uri; + + Image(String documentId, String name, Uri uri) { + this.documentId = documentId; + this.name = name; + this.uri = uri; + } + } + + /** Images directly in the folder, sorted naturally by name. Runs a content query; call off the UI thread. */ + public static List list(Context context, String treeUri) { + List images = new ArrayList<>(); + if (treeUri == null) return images; + try { + Uri tree = Uri.parse(treeUri); + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, + DocumentsContract.getTreeDocumentId(tree)); + try (Cursor c = context.getContentResolver().query(children, new String[]{ + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE}, null, null, null)) { + while (c != null && c.moveToNext()) { + String id = c.getString(0); + String name = c.getString(1); + String mime = c.getString(2); + if (name == null || DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) continue; + if (!isImage(name)) continue; + images.add(new Image(id, name, DocumentsContract.buildDocumentUriUsingTree(tree, id))); + } + } + } catch (Exception ignored) { + } + Collections.sort(images, (a, b) -> naturalCompare(a.name, b.name)); + return images; + } + + private static boolean isImage(String name) { + String lower = name.toLowerCase(Locale.ROOT); + for (String ext : EXTENSIONS) if (lower.endsWith(ext)) return true; + return false; + } + + /** Picks the next image for the play order; random avoids repeating the current one. */ + public static Image pick(List images, String currentId, String order) { + if (images.isEmpty()) return null; + int current = -1; + for (int i = 0; i < images.size(); i++) { + if (images.get(i).documentId.equals(currentId)) current = i; + } + if (PersonalizationManager.ORDER_SEQUENTIAL.equals(order)) { + return images.get((current + 1) % images.size()); + } + if (images.size() == 1) return images.get(0); + int next; + Random random = new Random(); + do { + next = random.nextInt(images.size()); + } while (next == current); + return images.get(next); + } + + /** Switches to the next folder image now. The callback runs on the main thread. */ + public static void next(Context context, Consumer callback) { + Context app = context.getApplicationContext(); + PersonalizationManager pm = new PersonalizationManager(app); + String folder = pm.getBackgroundFolder(); + if (folder == null) { + callback.accept(false); + return; + } + new Thread(() -> { + List images = list(app, folder); + Image image = pick(images, pm.getBackgroundFolderCurrent(), pm.getBackgroundPlayOrder()); + android.os.Handler main = new android.os.Handler(android.os.Looper.getMainLooper()); + if (image == null) { + main.post(() -> callback.accept(false)); + return; + } + main.post(() -> LauncherBackgroundController.importBackground(context, image.uri, ok -> { + if (ok) pm.setBackgroundFolderCurrent(image.documentId); + callback.accept(ok); + })); + }, "WallpaperFolder").start(); + } + + /** Once per process: a new wallpaper from the folder, like the Windows launcher on every start. */ + public static void rotateOnLaunch(Context context) { + if (sRotatedThisLaunch) return; + sRotatedThisLaunch = true; + if (new PersonalizationManager(context).getBackgroundFolder() == null) return; + next(context, ok -> { }); + } + + public static int count(Context context, String treeUri) { + return list(context, treeUri).size(); + } + + private static int naturalCompare(String a, String b) { + int i = 0, j = 0; + while (i < a.length() && j < b.length()) { + char ca = a.charAt(i), cb = b.charAt(j); + if (Character.isDigit(ca) && Character.isDigit(cb)) { + int si = i, sj = j; + while (i < a.length() && Character.isDigit(a.charAt(i))) i++; + while (j < b.length() && Character.isDigit(b.charAt(j))) j++; + String na = a.substring(si, i).replaceFirst("^0+(?=.)", ""); + String nb = b.substring(sj, j).replaceFirst("^0+(?=.)", ""); + if (na.length() != nb.length()) return na.length() - nb.length(); + int cmp = na.compareTo(nb); + if (cmp != 0) return cmp; + } else { + int cmp = Character.compare(Character.toLowerCase(ca), Character.toLowerCase(cb)); + if (cmp != 0) return cmp; + i++; + j++; + } + } + return (a.length() - i) - (b.length() - j); + } +} diff --git a/app/src/main/res/drawable/bg_shell_avatar.xml b/app/src/main/res/drawable/bg_shell_avatar.xml new file mode 100644 index 000000000..3bf3e6fab --- /dev/null +++ b/app/src/main/res/drawable/bg_shell_avatar.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/bg_shell_content.xml b/app/src/main/res/drawable/bg_shell_content.xml new file mode 100644 index 000000000..e35d77f51 --- /dev/null +++ b/app/src/main/res/drawable/bg_shell_content.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_shell_icon_button.xml b/app/src/main/res/drawable/bg_shell_icon_button.xml new file mode 100644 index 000000000..add47ed5e --- /dev/null +++ b/app/src/main/res/drawable/bg_shell_icon_button.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_shell_rail_indicator.xml b/app/src/main/res/drawable/bg_shell_rail_indicator.xml new file mode 100644 index 000000000..826531d2a --- /dev/null +++ b/app/src/main/res/drawable/bg_shell_rail_indicator.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_ui_card.xml b/app/src/main/res/drawable/bg_ui_card.xml new file mode 100644 index 000000000..e1fd2e359 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_card.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_card_inner.xml b/app/src/main/res/drawable/bg_ui_card_inner.xml new file mode 100644 index 000000000..e6b26e6af --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_card_inner.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_icon_chip.xml b/app/src/main/res/drawable/bg_ui_icon_chip.xml new file mode 100644 index 000000000..40fa9c424 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_icon_chip.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_input.xml b/app/src/main/res/drawable/bg_ui_input.xml new file mode 100644 index 000000000..64655404c --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_input.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_row.xml b/app/src/main/res/drawable/bg_ui_row.xml new file mode 100644 index 000000000..6049f4797 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_row.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_segment_selected.xml b/app/src/main/res/drawable/bg_ui_segment_selected.xml new file mode 100644 index 000000000..20a273586 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_segment_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_ui_segment_track.xml b/app/src/main/res/drawable/bg_ui_segment_track.xml new file mode 100644 index 000000000..cbb15e17b --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_segment_track.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_ui_strip.xml b/app/src/main/res/drawable/bg_ui_strip.xml new file mode 100644 index 000000000..f60099948 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_strip.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_tag.xml b/app/src/main/res/drawable/bg_ui_tag.xml new file mode 100644 index 000000000..dff5e0bc3 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_tag.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_ui_tag_accent.xml b/app/src/main/res/drawable/bg_ui_tag_accent.xml new file mode 100644 index 000000000..818f708d7 --- /dev/null +++ b/app/src/main/res/drawable/bg_ui_tag_accent.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/content_bg_icon_button.xml b/app/src/main/res/drawable/content_bg_icon_button.xml new file mode 100644 index 000000000..c09ef8b9e --- /dev/null +++ b/app/src/main/res/drawable/content_bg_icon_button.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/content_bg_item_card.xml b/app/src/main/res/drawable/content_bg_item_card.xml new file mode 100644 index 000000000..f84cfc04f --- /dev/null +++ b/app/src/main/res/drawable/content_bg_item_card.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/content_bg_thumb.xml b/app/src/main/res/drawable/content_bg_thumb.xml new file mode 100644 index 000000000..5046b0a4b --- /dev/null +++ b/app/src/main/res/drawable/content_bg_thumb.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_launch_group.xml b/app/src/main/res/drawable/home_bg_launch_group.xml new file mode 100644 index 000000000..02da69160 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_launch_group.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_ui_add.xml b/app/src/main/res/drawable/ic_ui_add.xml new file mode 100644 index 000000000..56006c0d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_add.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_arrow_back.xml b/app/src/main/res/drawable/ic_ui_arrow_back.xml new file mode 100644 index 000000000..49459f7a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_arrow_back.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_arrow_forward.xml b/app/src/main/res/drawable/ic_ui_arrow_forward.xml new file mode 100644 index 000000000..7ac50377c --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_arrow_forward.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_bolt.xml b/app/src/main/res/drawable/ic_ui_bolt.xml new file mode 100644 index 000000000..41bff499e --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_bolt.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_camera.xml b/app/src/main/res/drawable/ic_ui_camera.xml new file mode 100644 index 000000000..d1a09c3dc --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_camera.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_chevron_right.xml b/app/src/main/res/drawable/ic_ui_chevron_right.xml new file mode 100644 index 000000000..dc9ab11a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_chevron_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_cloud_download.xml b/app/src/main/res/drawable/ic_ui_cloud_download.xml new file mode 100644 index 000000000..d1785c03e --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_cloud_download.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_code.xml b/app/src/main/res/drawable/ic_ui_code.xml new file mode 100644 index 000000000..3893387b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_code.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_delete.xml b/app/src/main/res/drawable/ic_ui_delete.xml new file mode 100644 index 000000000..4530dd2e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_delete.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_download.xml b/app/src/main/res/drawable/ic_ui_download.xml new file mode 100644 index 000000000..60bff6a37 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_download.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_expand_more.xml b/app/src/main/res/drawable/ic_ui_expand_more.xml new file mode 100644 index 000000000..b447af9ce --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_expand_more.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_filter.xml b/app/src/main/res/drawable/ic_ui_filter.xml new file mode 100644 index 000000000..0fff5d9a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_filter.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_folder.xml b/app/src/main/res/drawable/ic_ui_folder.xml new file mode 100644 index 000000000..4cc16357f --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_folder.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_globe.xml b/app/src/main/res/drawable/ic_ui_globe.xml new file mode 100644 index 000000000..fec21d2ea --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_globe.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_group.xml b/app/src/main/res/drawable/ic_ui_group.xml new file mode 100644 index 000000000..1a61ba13a --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_group.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_heart.xml b/app/src/main/res/drawable/ic_ui_heart.xml new file mode 100644 index 000000000..3af800522 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_heart.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_image.xml b/app/src/main/res/drawable/ic_ui_image.xml new file mode 100644 index 000000000..269f2cbca --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_image.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_lightbulb.xml b/app/src/main/res/drawable/ic_ui_lightbulb.xml new file mode 100644 index 000000000..69e6413b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_lightbulb.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_monitor.xml b/app/src/main/res/drawable/ic_ui_monitor.xml new file mode 100644 index 000000000..b0c54c477 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_monitor.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_more.xml b/app/src/main/res/drawable/ic_ui_more.xml new file mode 100644 index 000000000..181da7248 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_more.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_open_in_new.xml b/app/src/main/res/drawable/ic_ui_open_in_new.xml new file mode 100644 index 000000000..913516262 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_open_in_new.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_palette.xml b/app/src/main/res/drawable/ic_ui_palette.xml new file mode 100644 index 000000000..38e034f99 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_palette.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_person.xml b/app/src/main/res/drawable/ic_ui_person.xml new file mode 100644 index 000000000..8628e48c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_person.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_puzzle.xml b/app/src/main/res/drawable/ic_ui_puzzle.xml new file mode 100644 index 000000000..9d8820398 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_puzzle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_refresh.xml b/app/src/main/res/drawable/ic_ui_refresh.xml new file mode 100644 index 000000000..11bf9a047 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_refresh.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_schedule.xml b/app/src/main/res/drawable/ic_ui_schedule.xml new file mode 100644 index 000000000..3972b27f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_schedule.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_search.xml b/app/src/main/res/drawable/ic_ui_search.xml new file mode 100644 index 000000000..d96ecf140 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_search.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_select.xml b/app/src/main/res/drawable/ic_ui_select.xml new file mode 100644 index 000000000..ed8b344fe --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_select.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_server.xml b/app/src/main/res/drawable/ic_ui_server.xml new file mode 100644 index 000000000..ebd329397 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_server.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_sort.xml b/app/src/main/res/drawable/ic_ui_sort.xml new file mode 100644 index 000000000..08f1b9783 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_sort.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_star.xml b/app/src/main/res/drawable/ic_ui_star.xml new file mode 100644 index 000000000..1e179660b --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_star.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_storage.xml b/app/src/main/res/drawable/ic_ui_storage.xml new file mode 100644 index 000000000..114fc954e --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_storage.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_sunrise.xml b/app/src/main/res/drawable/ic_ui_sunrise.xml new file mode 100644 index 000000000..535ad0db2 --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_sunrise.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_tune.xml b/app/src/main/res/drawable/ic_ui_tune.xml new file mode 100644 index 000000000..f863b36cb --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_tune.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ui_upload.xml b/app/src/main/res/drawable/ic_ui_upload.xml new file mode 100644 index 000000000..9b18adf1c --- /dev/null +++ b/app/src/main/res/drawable/ic_ui_upload.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/instances_bg_card_icon.xml b/app/src/main/res/drawable/instances_bg_card_icon.xml new file mode 100644 index 000000000..fb8fbfdcd --- /dev/null +++ b/app/src/main/res/drawable/instances_bg_card_icon.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/instances_bg_icon_slot.xml b/app/src/main/res/drawable/instances_bg_icon_slot.xml new file mode 100644 index 000000000..423900493 --- /dev/null +++ b/app/src/main/res/drawable/instances_bg_icon_slot.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/instances_bg_search.xml b/app/src/main/res/drawable/instances_bg_search.xml new file mode 100644 index 000000000..1573923b6 --- /dev/null +++ b/app/src/main/res/drawable/instances_bg_search.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/instances_ic_check_box.xml b/app/src/main/res/drawable/instances_ic_check_box.xml new file mode 100644 index 000000000..093c3ffc0 --- /dev/null +++ b/app/src/main/res/drawable/instances_ic_check_box.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/misc_bg_avatar.xml b/app/src/main/res/drawable/misc_bg_avatar.xml new file mode 100644 index 000000000..d1dfa59b7 --- /dev/null +++ b/app/src/main/res/drawable/misc_bg_avatar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/misc_bg_tag_danger.xml b/app/src/main/res/drawable/misc_bg_tag_danger.xml new file mode 100644 index 000000000..50bd5d578 --- /dev/null +++ b/app/src/main/res/drawable/misc_bg_tag_danger.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/settings_ic_layers.xml b/app/src/main/res/drawable/settings_ic_layers.xml new file mode 100644 index 000000000..f805347c9 --- /dev/null +++ b/app/src/main/res/drawable/settings_ic_layers.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 1e0ecac46..1c293c4ea 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -1,361 +1,555 @@ - + android:fillViewport="true" + android:scrollbars="none"> - + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="16dp"> - + - - - - + android:layout_height="wrap_content"> + android:padding="18dp"> + + + android:layout_marginTop="6dp" + android:text="@string/misc_about_subtitle" + android:textAppearance="@style/TextAppearance.Levi.PageSubtitle" /> + + + + + + + + android:orientation="vertical" + android:padding="18dp"> - + + + + + + + + + + + android:layout_marginTop="14dp" + android:background="@drawable/bg_ui_card_inner" + android:gravity="center_vertical" + android:minHeight="72dp" + android:orientation="horizontal" + android:padding="12dp"> + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:text="@string/about_author_role" + android:textAppearance="@style/TextAppearance.Levi.Caption" /> + - + + + + + + android:layout_marginStart="12dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + + + - - + + - + - + + + + + + + + - + - - + android:orientation="vertical" + android:padding="18dp"> + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + + + + android:layout_marginStart="12dp" + android:text="@string/misc_about_thanks" + android:textAppearance="@style/TextAppearance.Levi.SectionTitle" /> + - + - - + + + + + + + android:text="@string/misc_about_rhymc" + android:textColor="@color/text_primary" + android:textSize="15sp" + android:textStyle="bold" /> + + + + + + + + + + + + + + + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:text="@string/misc_about_sponsors" + android:textAppearance="@style/TextAppearance.Levi.SectionTitle" /> + + + + + + + android:baselineAligned="false" + android:orientation="horizontal" + android:padding="18dp"> - - + android:orientation="vertical"> - + + + + + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:text="@string/about_source_title" + android:textAppearance="@style/TextAppearance.Levi.SectionTitle" /> + + + + + + + + + + + + + android:text="@string/misc_about_license_tip" + android:textAppearance="@style/TextAppearance.Levi.Caption" /> + + + android:orientation="vertical"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + android:layout_marginTop="14dp" + android:lineSpacingMultiplier="1.15" + android:text="@string/misc_about_contribute_desc" + android:textAppearance="@style/TextAppearance.Levi.Body" /> - + android:textSize="13sp" + app:icon="@drawable/ic_github" /> - + android:textSize="13sp" + app:icon="@drawable/ic_ui_star" /> - - - - + + + diff --git a/app/src/main/res/layout/activity_accounts.xml b/app/src/main/res/layout/activity_accounts.xml index aae6f1c39..3c9e2b7d9 100644 --- a/app/src/main/res/layout/activity_accounts.xml +++ b/app/src/main/res/layout/activity_accounts.xml @@ -1,212 +1,294 @@ - + android:orientation="vertical" + android:padding="16dp"> - - + - - - - + android:layout_height="wrap_content"> - - - - - + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="18dp"> - - - - - - + - + + - - - + + + -