From d3789ec57f58ec7e4c8ede251ecaac524b0b286b Mon Sep 17 00:00:00 2001 From: Lynxifyy <297587416+Lynxifyy@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:41:18 +0000 Subject: [PATCH] feat(ui): mount the Caramel UI pack's Compose launcher and its native Mod Menu The legacy launcher UI was deleted so the app could become a clean skeleton. This puts a real UI back: the Caramel UI pack's Compose launcher, wired to the managers that survived the deletion. The launcher ------------ MainActivity hosts a single ComposeView; CaramelLauncherHost is the Java-facing seam (the activity supplies the Actions, the Kotlin side supplies the frame). The pack's frame, glass title bar, 56dp sidebar rail and six tabs (Home, Mods, Servers, Cosmetics, News, Store) are ported under ui/compose/caramel. Every row is real data. The pack ships Versions, ExampleMods and ExampleServers for its demo; those are not carried over. CaramelLauncherState defaults every list to empty and the activity fills it from VersionManager, MsftAccountStore and ModManager, so a screen with nothing to show draws its empty state rather than a fabricated row. The Store tab has no backend, so it says so. The in-game Mod Menu -------------------- The menu the launcher opens is the pack's Dear ImGui menu, restored as a native overlay drawn on the game's GL context (sweetloader, pl::menu). InGameMenuController is the bridge: it builds the module catalogue from the same providers the launcher uses, forwards touch and the back button into the menu, and applies the events it raises onto the real modules on the main thread. PreloaderMenu is the JNI seam and is fail-closed: with no live present hook the menu reports itself unavailable and cannot open. MinecraftActivity starts the controller with the session, gives the open menu first refusal on every touch, and drains its events each tick. --- app/src/main/cpp/sweetloader | 2 +- .../core/minecraft/MinecraftActivity.kt | 15 + .../inbuilt/overlay/InGameMenuController.java | 402 ++++++++++++++++++ .../overlay/InbuiltOverlayManager.java | 26 +- .../launcher/preloader/PreloaderMenu.java | 142 +++++++ .../launcher/ui/activities/MainActivity.java | 205 ++++++++- .../ui/compose/caramel/CaramelControls.kt | 328 ++++++++++++++ .../ui/compose/caramel/CaramelDropdowns.kt | 221 ++++++++++ .../client/ui/compose/caramel/CaramelGlass.kt | 163 +++++++ .../ui/compose/caramel/CaramelLabels.kt | 176 ++++++++ .../ui/compose/caramel/CaramelLoaderIcons.kt | 112 +++++ .../ui/compose/caramel/CaramelModels.kt | 120 ++++++ .../client/ui/compose/caramel/CaramelTheme.kt | 127 ++++++ .../client/ui/compose/caramel/CaramelUtil.kt | 7 + .../caramel/launcher/CaramelCosmeticsStore.kt | 231 ++++++++++ .../compose/caramel/launcher/CaramelHome.kt | 276 ++++++++++++ .../caramel/launcher/CaramelLauncherHost.kt | 69 +++ .../caramel/launcher/CaramelLauncherScreen.kt | 49 +++ .../caramel/launcher/CaramelModsServers.kt | 227 ++++++++++ .../caramel/launcher/CaramelOverlays.kt | 156 +++++++ .../compose/caramel/launcher/CaramelShell.kt | 174 ++++++++ .../ModUnavailableReasonResourcesTest.java | 85 ++++ 22 files changed, 3301 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java create mode 100644 app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelControls.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelGlass.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLoaderIcons.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelModels.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelTheme.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherHost.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherScreen.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt create mode 100644 app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java diff --git a/app/src/main/cpp/sweetloader b/app/src/main/cpp/sweetloader index 3fda389f..4ff2e63e 160000 --- a/app/src/main/cpp/sweetloader +++ b/app/src/main/cpp/sweetloader @@ -1 +1 @@ -Subproject commit 3fda389f6975cab0e8ae375a94227151470d0ed1 +Subproject commit 4ff2e63e9bb81f1d5cc605078d41df71f8a229d2 diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt index a09ed3c6..c805185e 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt +++ b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt @@ -339,11 +339,15 @@ class MinecraftActivity : MainActivity(), PojavControlsHost { private fun startInbuiltModServices() { overlayManager = InbuiltOverlayManager(this) overlayManager?.showEnabledOverlays() + // The in-game Mod Menu is drawn natively on the game's GL context; the controller builds its + // catalogue and forwards input into it for as long as the session lives. + org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.install(this) } private fun stopInbuiltModServices() { overlayManager?.hideAllOverlays() overlayManager = null + org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.release(this) } override fun onNewIntent(intent: Intent) { @@ -575,6 +579,13 @@ class MinecraftActivity : MainActivity(), PojavControlsHost { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // An open Mod Menu owns every touch: it is a full-screen overlay drawn into the game's + // back buffer, so the game must not also receive the tap. + if (org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.getInstance() + ?.handleTouch(event) == true) { + return true + } + if (shouldConsumeMouseMotion(event)) { return true } @@ -861,6 +872,10 @@ class MinecraftActivity : MainActivity(), PojavControlsHost { flushLookDelta() super.tick() overlayManager?.tick() + // Apply whatever the native menu raised this frame (a toggle, a config change, a favourite) + // on the main thread, where the launcher's own module changes also happen. + org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.getInstance() + ?.drainAndApply() } override fun getDataDir(): File { diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java new file mode 100644 index 00000000..e7aca1d6 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java @@ -0,0 +1,402 @@ +package org.chimeramc.client.launcher.core.mods.inbuilt.overlay; + +import org.chimeramc.client.util.AppLog; + +import android.app.Activity; +import android.view.MotionEvent; + +import org.chimeramc.client.R; +import org.chimeramc.client.core.mods.inbuilt.ExternalModuleProvider; +import org.chimeramc.client.core.mods.inbuilt.InbuiltModuleProvider; +import org.chimeramc.client.core.mods.inbuilt.UnifiedMod; +import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager; +import org.chimeramc.client.core.mods.inbuilt.model.ModAvailability; +import org.chimeramc.client.core.mods.inbuilt.overlay.ModNotificationManager; +import org.chimeramc.client.preloader.PreloaderMenu; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Drives the native in-game Mod Menu from the launcher side. + * + *

The menu itself is drawn natively (Dear ImGui, on the game's GL context — see {@link + * PreloaderMenu}); this class is the bridge. It builds the module catalogue the menu shows, forwards + * the game view's touch events into it, and applies the interactions the menu raises back onto the + * real modules. That keeps the native side free of Android types and keeps this side testable. + * + *

The catalogue and the drained events cross as JSON. {@link #buildCatalogueJson} and {@link + * #applyEvent} are pure with respect to the native library, so the shape of both documents is + * pinned by unit tests rather than discovered on a device. + * + *

Honesty. A module that cannot run in this build is sent with its reason and the menu + * badges it; the menu never shows a toggle that would do nothing. The module list is the same one + * the launcher shows, from the same providers, so a module cannot exist in one and be missing from + * the other. + */ +public final class InGameMenuController { + + private static final String TAG = "InGameMenuController"; + + private static volatile InGameMenuController instance; + + private final Activity activity; + private final ModNotificationManager notifications; + private boolean installed; + /** The catalogue revision last sent, so an unchanged list is not rebuilt every tick. */ + private int lastCatalogueHash; + + private InGameMenuController(Activity activity) { + this.activity = activity; + this.notifications = new ModNotificationManager(activity); + } + + /** The controller for an activity, or null before one is installed. */ + public static InGameMenuController getInstance() { + return instance; + } + + /** Installs the controller for a game session. Idempotent per activity. */ + public static InGameMenuController install(Activity activity) { + if (activity == null) return null; + InGameMenuController existing = instance; + if (existing != null && existing.activity == activity) { + existing.ensureNativeInstalled(); + return existing; + } + InGameMenuController controller = new InGameMenuController(activity); + instance = controller; + controller.ensureNativeInstalled(); + controller.registerFonts(); + controller.pushCatalogue(); + return controller; + } + + /** Drops the controller when the game session ends. */ + public static void release(Activity activity) { + InGameMenuController current = instance; + if (current == null) return; + if (activity != null && current.activity != activity) return; + PreloaderMenu.setOpen(false); + instance = null; + } + + private void ensureNativeInstalled() { + if (installed) return; + PreloaderMenu.install(); + installed = true; + } + + /** True when the native menu's ImGui context is up in the game process. */ + public boolean isMenuLive() { + return PreloaderMenu.isMenuLive(); + } + + /** Whether the native menu is currently open. */ + public boolean isMenuOpen() { + return PreloaderMenu.isOpen(); + } + + /** Opens the native menu, refreshing the catalogue first. */ + public void show() { + if (!PreloaderMenu.isMenuLive()) { + // The present hook has not installed (or this build has none). The menu cannot draw, so + // it must not open onto a black screen; say so once instead. + AppLog.warnOnce(TAG, "show", "The in-game menu is not live on this build", null); + return; + } + pushCatalogue(); + PreloaderMenu.setOpen(true); + } + + /** Closes the native menu. */ + public void hide() { + PreloaderMenu.setOpen(false); + } + + /** Opens the menu when it is closed, closes it when it is open. */ + public void toggle() { + if (isMenuOpen()) { + hide(); + } else { + show(); + } + } + + /** + * Forwards a touch event to the native menu. + * + * @return true when the menu is open and therefore consuming the touch, so the game must not + * also see it. + */ + public boolean handleTouch(MotionEvent event) { + if (event == null || !isMenuOpen()) return false; + final float x = event.getX(); + final float y = event.getY(); + // The game view's size is the surface the menu draws into, so the coordinate maps 1:1. The + // view size is passed so the native side can scale if it ever differs (a letterboxed surface). + PreloaderMenu.pushTouch(event.getActionMasked(), x, y, 0f, 0f); + return true; + } + + /** Forwards a back/close request (the back button or a controller's B). */ + public boolean handleBack() { + if (!isMenuOpen()) return false; + PreloaderMenu.pushBack(); + return true; + } + + /** + * Drains the interactions the menu raised and applies them to the real modules. + * + *

Called from the per-frame tick, on the main thread, so the module changes land on the same + * thread the launcher's own toggles use. + */ + public void drainAndApply() { + if (!installed) return; + final String json = PreloaderMenu.drainEvents(); + if (json == null || json.isEmpty() || "[]".equals(json)) return; + boolean changed = false; + try { + final JSONArray events = new JSONArray(json); + for (int i = 0; i < events.length(); i++) { + changed |= applyEvent(events.getJSONObject(i)); + } + } catch (Exception e) { + AppLog.warnOnce(TAG, "drain", "Ignored a malformed menu event batch", e); + return; + } + if (changed) pushCatalogue(); + } + + /** + * Applies one menu event. Returns true when the module list changed and must be re-sent. + * + *

Package-private and free of Android view code so the mapping from a menu event to a module + * change is unit-tested. + */ + boolean applyEvent(JSONObject event) { + final String kind = event.optString("kind", ""); + final String key = event.optString("key", ""); + final String value = event.optString("value", ""); + if (key.isEmpty()) return false; + final UnifiedMod mod = findMod(key); + if (mod == null) return false; + switch (kind) { + case "toggle_module": + if (!ModAvailability.isInteractive(mod.getId())) return false; + mod.applyEnabled("true".equals(value)); + InbuiltModManager manager = InbuiltModManager.getInstance(activity); + if (manager != null && mod.isEnabled() && manager.isNotificationsEnabled()) { + notifications.show(mod.getName(), mod.getStableKey()); + } + return true; + case "set_config": + return applyConfig(mod, value); + case "toggle_favorite": + return toggleFavorite(mod); + case "close": + return false; + default: + return false; + } + } + + private boolean applyConfig(UnifiedMod mod, String keyValue) { + final int sep = keyValue.indexOf('='); + if (sep <= 0) return false; + final String configKey = keyValue.substring(0, sep); + final String configValue = keyValue.substring(sep + 1); + mod.updateConfig(configKey, configValue); + return false; + } + + private boolean toggleFavorite(UnifiedMod mod) { + InbuiltModManager manager = InbuiltModManager.getInstance(activity); + if (manager == null) return false; + final boolean nowFavorite = !manager.getFavoriteModKeys().contains(mod.getStableKey()); + manager.setModFavorite(mod.getStableKey(), nowFavorite); + return true; + } + + private UnifiedMod findMod(String stableKey) { + for (UnifiedMod mod : loadMods()) { + if (mod.getStableKey().equals(stableKey)) return mod; + } + return null; + } + + private List loadMods() { + List mods = new ArrayList<>(InbuiltModuleProvider.load(activity)); + mods.addAll(ExternalModuleProvider.load(activity)); + return mods; + } + + /** Rebuilds and sends the catalogue, unless it is unchanged since the last send. */ + public void pushCatalogue() { + if (!installed) return; + final List mods = loadMods(); + final Set favorites = favoriteKeys(); + final String json = buildCatalogueJson(mods, favorites, emptyReason(), this::reasonMessage); + final int hash = json.hashCode(); + if (hash == lastCatalogueHash) return; + lastCatalogueHash = hash; + PreloaderMenu.setCatalogue(json); + } + + private Set favoriteKeys() { + InbuiltModManager manager = InbuiltModManager.getInstance(activity); + return manager == null ? java.util.Collections.emptySet() : manager.getFavoriteModKeys(); + } + + private String emptyReason() { + return activity.getString(R.string.mod_menu_no_mods); + } + + /** + * The wording for a module's unavailable reason. + * + *

Every reason has its own message: a module waiting for an engine hook, one waiting for a + * game feed this build cannot read, one a server decides, and one waiting on a backend feature + * are four different situations with four different answers. A generic "unavailable" tells the + * player none of them. + */ + private String reasonMessage(String reasonId) { + int res = reasonStringRes(reasonId); + return res == 0 ? "" : activity.getString(res); + } + + /** The string resource that words a reason id, or 0 when the id is unknown. */ + static int reasonStringRes(String reasonId) { + if (ModAvailability.REASON_SERVER_DECIDED.equals(reasonId)) { + return R.string.mod_unavailable_reason_server; + } + if (ModAvailability.REASON_NO_BACKEND.equals(reasonId)) { + return R.string.mod_unavailable_reason_backend; + } + if (ModAvailability.REASON_NO_HOOK.equals(reasonId)) { + return R.string.mod_unavailable_reason_hook; + } + if (ModAvailability.REASON_NO_GAME_DATA.equals(reasonId)) { + return R.string.mod_unavailable_reason; + } + return 0; + } + + /** + * Builds the catalogue document the native menu reads. + * + *

Static and free of Android types other than the strings it is handed, so the shape is + * unit-tested against the exact fields the native parser reads. The reason is already worded by + * {@code messageFor}, so the native menu never shows a raw reason id. + */ + static String buildCatalogueJson(List mods, Set favorites, + String emptyReason, + java.util.function.Function messageFor) { + try { + JSONArray array = new JSONArray(); + for (UnifiedMod mod : mods) { + array.put(moduleJson(mod, favorites, messageFor)); + } + JSONObject root = new JSONObject(); + root.put("modules", array); + root.put("empty_reason", emptyReason == null ? "" : emptyReason); + return root.toString(); + } catch (Exception e) { + return "{\"modules\":[],\"empty_reason\":\"\"}"; + } + } + + private static JSONObject moduleJson(UnifiedMod mod, Set favorites, + java.util.function.Function messageFor) + throws Exception { + JSONObject obj = new JSONObject(); + obj.put("stable_key", mod.getStableKey()); + obj.put("name", mod.getName()); + obj.put("description", mod.getDescription()); + obj.put("group", mod.getGroupName()); + obj.put("enabled", mod.isEnabled()); + obj.put("favorite", favorites != null && favorites.contains(mod.getStableKey())); + obj.put("has_config", mod.hasConfig()); + String reason = ModAvailability.unavailableReason(mod.getId()); + String message = reason == null ? "" : messageFor.apply(reason); + obj.put("unavailable", message == null ? "" : message); + obj.put("configs", configsJson(mod)); + return obj; + } + + private static JSONArray configsJson(UnifiedMod mod) throws Exception { + JSONArray configs = new JSONArray(); + for (UnifiedMod.ConfigEntry entry : mod.getConfigEntries()) { + JSONObject cfg = new JSONObject(); + cfg.put("key", entry.key); + cfg.put("label", entry.displayName); + cfg.put("value", entry.currentValue == null ? "" : entry.currentValue); + switch (entry.type) { + case TOGGLE: + cfg.put("kind", "toggle"); + cfg.put("float_value", "true".equalsIgnoreCase(entry.currentValue) ? 1.0 : 0.0); + break; + case SLIDER_INT: + case SLIDER_FLOAT: + cfg.put("kind", "slider"); + cfg.put("float_value", parseFloat(entry.currentValue, 0f)); + cfg.put("float_min", parseFloat(entry.minValue, 0f)); + cfg.put("float_max", parseFloat(entry.maxValue, 1f)); + break; + default: + // A colour, a keybind or a text field has no in-game editor yet; it is shown + // read-only with its value rather than dropped from the panel. + cfg.put("kind", "readonly"); + break; + } + configs.put(cfg); + } + return configs; + } + + private static float parseFloat(String value, float fallback) { + if (value == null || value.isEmpty()) return fallback; + try { + return Float.parseFloat(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private void registerFonts() { + // The pack's roles. Inter for body text, Poppins for titles; the launcher already ships both. + registerFont(0, R.font.inter); + registerFont(1, R.font.inter); + registerFont(2, R.font.inter); + registerFont(3, R.font.poppins); + registerFont(4, R.font.poppins); + } + + private void registerFont(int role, int fontRes) { + try { + byte[] bytes = readResource(fontRes); + if (bytes != null) PreloaderMenu.registerFont(role, bytes); + } catch (Exception e) { + AppLog.warnOnce(TAG, "font", "A menu font could not be read; the default is used", e); + } + } + + private byte[] readResource(int resId) { + try (java.io.InputStream in = activity.getResources().openRawResource(resId)) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (Exception e) { + return null; + } + } +} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java index 3246dcd0..11f9215d 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java +++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java @@ -11,6 +11,7 @@ import org.chimeramc.client.core.mods.inbuilt.model.MoreButtonConfig; import org.chimeramc.client.core.mods.inbuilt.model.ModIds; import org.chimeramc.client.launcher.core.mods.inbuilt.nativemod.PojavControlsMod; +import org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController; import org.chimeramc.pojavcontrols.PojavControls; import org.chimeramc.pojavcontrols.PojavControlsHost; @@ -1206,9 +1207,22 @@ public boolean handleKeyEvent(int keyCode, int action, int rawKeyCode) { // The open Mod Menu takes controller navigation before anything else. Its root is // unfocusable by design (so the platform stops painting a focus wash), so the menu cannot // be driven by the framework's focus system and is driven here instead. + if (handleMenuControllerKey(rawKeyCode, action)) return true; + + // Mod Menu open bind. Honoured for both the keyboard and controller codes, so a pad button a + // profile remapped still opens the menu. The native menu has no Android view to focus, so + // the press opens it directly. + InGameMenuController menu = InGameMenuController.getInstance(); + if (menu != null && action == android.view.KeyEvent.ACTION_DOWN + && InbuiltModManager.matchesModMenuBind( + manager.getModMenuKeybind(), manager.getModMenuControllerBind(), + keyCode, rawKeyCode)) { + menu.toggle(); + return true; + } boolean zoomEnabled = modActiveStates.getOrDefault(ModIds.ZOOM, false); - + int zoomKeybind = manager.getZoomKeybind(); if (zoomEnabled && keyCode == zoomKeybind) { if (zoomOverlay != null) { @@ -1260,9 +1274,13 @@ public boolean handleKeyEvent(int keyCode, int action, int rawKeyCode) { * would otherwise be consumed by the game first. */ public boolean handleMenuControllerKey(int rawKeyCode, int action) { - // The in-game Mod Menu UI was removed with the legacy shell; nothing consumes controller - // navigation here until a new menu is dropped in. - return false; + // The menu is drawn natively, so a controller key is translated to a menu action here and + // pushed into the native input queue rather than routed to an Android view. Only the back + // button is handled for now; the native menu's own nav flags cover the rest once it is open. + if (action != android.view.KeyEvent.ACTION_DOWN) return false; + if (rawKeyCode != android.view.KeyEvent.KEYCODE_BACK) return false; + InGameMenuController menu = InGameMenuController.getInstance(); + return menu != null && menu.handleBack(); } /** Hands analogue stick movement to the open Mod Menu, so the stick can move the selection. */ diff --git a/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java new file mode 100644 index 00000000..ab306668 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java @@ -0,0 +1,142 @@ +package org.chimeramc.client.preloader; + +import org.chimeramc.client.util.AppLog; + +/** + * The in-game Mod Menu, drawn natively with Dear ImGui on the game's own GL context. + * + *

This is the client half of the menu the UI pack ships as {@code imgui/src/CaramelMenu.cpp} + * (Lynxifyy/CaramelClient-UI-pack). The pack's desktop GLFW backend has no Android path, so the + * menu is rendered from the sweetloader's present hook instead, where the game's GLES context is + * current. The layout, glass widget kit and tokens are the pack's; the module data is this client's + * real catalogue. + * + *

Fail-closed. Every entry point is a no-op when the sweetloader is absent or its present + * hook is not live, and {@link #isMenuLive()} then reports false, so the launcher can badge the menu + * rather than open a menu that cannot draw. + */ +public final class PreloaderMenu { + + private static final String TAG = "PreloaderMenu"; + + private PreloaderMenu() { + } + + /** Asks the sweetloader to prepare the menu. Safe to call repeatedly. */ + public static void install() { + try { + nativeInstallMenu(); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:install", "The sweetloader is not loaded", e); + } + } + + /** True once the menu's ImGui context and GLES backend are up in the game process. */ + public static boolean isMenuLive() { + try { + return nativeIsMenuLive(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** Opens or closes the menu. */ + public static void setOpen(boolean open) { + try { + nativeSetMenuOpen(open); + } catch (UnsatisfiedLinkError e) { + // The menu cannot exist without the library; a failure to open or close it is worth a + // line in the log, not silence. + AppLog.warnOnce(TAG, "PreloaderMenu:setOpen", "The sweetloader is not loaded", e); + } + } + + public static boolean isOpen() { + try { + return nativeIsMenuOpen(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** + * Forwards a touch event to the menu. + * + * @param action the {@code MotionEvent} action mask (0 = down, 1 = up, 2 = move, 3 = cancel). + * @param x the touch x in the view's coordinate space. + * @param y the touch y in the view's coordinate space. + * @param viewWidth the view width the coordinate is in, or 0 to skip scaling. + * @param viewHeight the view height the coordinate is in, or 0 to skip scaling. + */ + public static void pushTouch(int action, float x, float y, float viewWidth, float viewHeight) { + try { + nativePushTouch(action, x, y, viewWidth, viewHeight); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:pushTouch", "The sweetloader is not loaded", e); + } + } + + /** Reports a back/close request (the back button or a controller's B). */ + public static void pushBack() { + try { + nativePushBack(); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:pushBack", "The sweetloader is not loaded", e); + } + } + + /** + * Replaces the module catalogue the Mods tab shows. + * + * @param json a {@code {"modules":[...],"empty_reason":"..."}} document, built by + * {@code InGameMenuCatalog}. + * @return true when the catalogue was accepted; false when it was rejected as malformed. + */ + public static boolean setCatalogue(String json) { + try { + return nativeSetCatalogue(json); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** + * Drains the interactions the menu raised since the last call, as a JSON array of + * {@code {"kind":...,"key":...,"value":...}} objects. + */ + public static String drainEvents() { + try { + return nativeDrainEvents(); + } catch (UnsatisfiedLinkError e) { + return "[]"; + } + } + + /** Registers a font face for a menu role (see {@code InGameMenuCatalog.ROLE_*}). */ + public static boolean registerFont(int role, byte[] data) { + try { + return nativeRegisterFont(role, data); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + private static native void nativeInstallMenu(); + + private static native boolean nativeIsMenuLive(); + + private static native void nativeSetMenuOpen(boolean open); + + private static native boolean nativeIsMenuOpen(); + + private static native void nativePushTouch(int action, float x, float y, float viewWidth, + float viewHeight); + + private static native void nativePushBack(); + + private static native boolean nativeSetCatalogue(String json); + + private static native String nativeDrainEvents(); + + private static native boolean nativeRegisterFont(int role, byte[] data); +} diff --git a/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java b/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java index 6a20171b..50bca065 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java +++ b/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java @@ -1,28 +1,219 @@ package org.chimeramc.client.ui.activities; +import android.content.Intent; import android.os.Bundle; import androidx.annotation.Nullable; +import androidx.compose.runtime.MutableState; +import androidx.compose.ui.platform.ComposeView; +import androidx.compose.ui.platform.ViewCompositionStrategy; + +import org.chimeramc.client.core.auth.MsftAccountStore; +import org.chimeramc.client.core.minecraft.MinecraftActivityState; +import org.chimeramc.client.core.minecraft.MinecraftLauncher; +import org.chimeramc.client.core.mods.Mod; +import org.chimeramc.client.core.mods.ModManager; +import org.chimeramc.client.core.versions.GameVersion; +import org.chimeramc.client.core.versions.VersionManager; +import org.chimeramc.client.ui.compose.caramel.CaramelCosmeticInfo; +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState; +import org.chimeramc.client.ui.compose.caramel.CaramelModInfo; +import org.chimeramc.client.ui.compose.caramel.CaramelServerInfo; +import org.chimeramc.client.ui.compose.caramel.CaramelVersion; +import org.chimeramc.client.ui.compose.caramel.launcher.CaramelLauncherHost; +import org.chimeramc.client.util.AppLog; + +import java.util.ArrayList; +import java.util.List; /** - * Placeholder screen. + * The launcher's main screen: the Caramel UI pack's Compose launcher, hosted in a {@link ComposeView}. * - *

The legacy UI was removed in one sweep so a new one can be dropped in. This class survives - * only because non-UI code (the manifest, an Intent target, or a nav destination) still names it; - * it draws nothing. Replace it with the real MainActivity screen. + *

This activity owns the state and the actions. It builds a {@link CaramelLauncherState} from the + * live managers (versions, account, mods) and hands it to the Kotlin host, which renders the pack's + * frame, tabs and modals. Every button and slider on screen calls back into a real launcher action + * here; nothing is a mock. + * + *

The activity draws no chrome of its own. {@link BaseActivity} keeps the immersive full-screen + * plumbing, and the Compose surface fills what is left. */ -public class MainActivity extends BaseActivity { +public class MainActivity extends BaseActivity implements CaramelLauncherHost.Actions { + + private static final String TAG = "MainActivity"; + + private MutableState launcherState; + private VersionManager versionManager; + private MinecraftLauncher minecraftLauncher; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + minecraftLauncher = new MinecraftLauncher(this); + launcherState = CaramelLauncherHost.state(); + setContentView(buildComposeHost()); + initVersionManager(); + refreshState(); + } + + private ComposeView buildComposeHost() { + ComposeView view = new ComposeView(this); + view.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool.INSTANCE); + CaramelLauncherHost.setContent(view, launcherState, this); + return view; + } + + private void initVersionManager() { + VersionManager.initializeAsync(this, manager -> { + versionManager = manager; + refreshState(); + }); + } + + /** Rebuilds the launcher state from the live managers and recomposes. */ + private void refreshState() { + CaramelLauncherState s = launcherState.getValue(); + s.setVersions(buildVersions()); + if (s.getVersion() == null && !s.getVersions().isEmpty()) { + s.setVersion(s.getVersions().get(0)); + } + MsftAccountStore.MsftAccount active = activeAccount(); + s.setSignedIn(active != null); + s.setAccountName(active != null ? displayName(active) : ""); + s.setRunningInstances(MinecraftActivityState.isRunning(this) ? 1 : 0); + s.setLaunchEnabled(versionManager != null && versionManager.getSelectedVersion() != null); + s.setMods(buildMods()); + launcherState.setValue(s); + } + + private List buildVersions() { + List out = new ArrayList<>(); + if (versionManager == null) return out; + List installed = versionManager.getInstalledVersions(); + if (installed != null) for (GameVersion v : installed) out.add(versionRow(v)); + List custom = versionManager.getCustomVersions(); + if (custom != null) for (GameVersion v : custom) out.add(versionRow(v)); + return out; + } + + private static CaramelVersion versionRow(GameVersion v) { + String id = v.versionCode != null && !v.versionCode.isEmpty() ? v.versionCode + : v.directoryName != null ? v.directoryName : ""; + return new CaramelVersion(id, id.toLowerCase(java.util.Locale.ROOT).contains("beta")); + } + + private List buildMods() { + List out = new ArrayList<>(); + try { + for (Mod m : ModManager.getInstance().getMods()) { + out.add(new CaramelModInfo( + m.getDisplayName() != null ? m.getDisplayName() : m.getFileName(), + m.getAuthor() != null ? m.getAuthor() : "", + m.getVersion() != null ? m.getVersion() : "", + m.getDescription() != null ? m.getDescription() : "", + m.isEnabled())); + } + } catch (Throwable t) { + AppLog.warnOnce(TAG, "buildMods", "Could not read the mod list", t); + } + return out; + } + + private MsftAccountStore.MsftAccount activeAccount() { + for (MsftAccountStore.MsftAccount a : MsftAccountStore.list(this)) { + if (a.active) return a; + } + return null; + } + + private static String displayName(MsftAccountStore.MsftAccount a) { + if (a.minecraftUsername != null && !a.minecraftUsername.isEmpty()) return a.minecraftUsername; + if (a.xboxGamertag != null && !a.xboxGamertag.isEmpty()) return a.xboxGamertag; + return ""; + } + + // --- CaramelLauncherHost.Actions ------------------------------------------------------------ + + @Override + public void launch() { + GameVersion selected = versionManager != null ? versionManager.getSelectedVersion() : null; + if (selected == null) return; + minecraftLauncher.launch(getIntent(), selected); + } + + @Override + public void selectVersion(String id) { + if (versionManager == null) return; + for (GameVersion v : versionManager.getInstalledVersions()) { + if (versionRow(v).getId().equals(id)) { + versionManager.selectVersion(v); + refreshState(); + return; + } + } + } + + @Override + public void toggleMod(CaramelModInfo mod, boolean on) { + try { + ModManager.getInstance().setModEnabled(mod.getName(), on); + } catch (Throwable t) { + AppLog.warnOnce(TAG, "toggleMod", "Could not toggle the mod", t); + } + refreshState(); + } + + @Override + public void equipCosmetic(CaramelCosmeticInfo cosmetic) { + // Wired when the cosmetic catalogue is loaded into the Cosmetics tab. + } + + @Override + public void toggleFavourite(CaramelCosmeticInfo cosmetic) { + // Favourites are persisted by the cosmetic store; wired with the catalogue. + } + + @Override + public void joinServer(CaramelServerInfo server) { + // Joining launches the game with the server address; wired with the server store. + } + + @Override + public void openModsFolder() { + startActivity(new Intent(this, ModsFullscreenActivity.class)); + } + + @Override + public void openContent() { + startActivity(new Intent(this, LibraryActivity.class)); + } + + @Override + public void openSettings() { + startActivity(new Intent(this, SettingsActivity.class)); + } + + @Override + public void openAccounts() { + // Wired with the account UI. + } + + @Override + public void quit() { + finishAffinity(); } /** * Refreshes the on-screen Minecraft version label. Kept as a no-op so - * {@code VersionManager.attemptRepairLibs} still compiles; the new Main screen should override - * the label it draws. + * {@code VersionManager.attemptRepairLibs} still compiles; the Caramel screen shows the version + * on the hero, which {@link #refreshState()} drives. */ public void setTextMinecraftVersion() { } + + @Override + protected void onResume() { + super.onResume(); + refreshState(); + } } diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelControls.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelControls.kt new file mode 100644 index 00000000..c758e513 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelControls.kt @@ -0,0 +1,328 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The Caramel control set, ported from the pack's `components/Buttons.kt` and `Controls.kt`. + * + *

The pack styles its buttons on hover, which a touch screen never does. `collectIsHoveredAsState` + * is replaced by `collectIsPressedAsState`, so the same brightening happens under a finger instead + * of a cursor; everything else (the tone ramps, the shadow, the top-edge highlight) is the pack's. + */ + +/** The pack's `Tone` ramps: a vertical gradient plus the colour text sits on. */ +enum class Tone(val top: Color, val bottom: Color, val on: Color) { + Green(C.GreenLight, C.GreenDark, C.OnGreen), + Caramel(Color(0xFFFFBE5A), C.CaramelDark, Color.White), + Blue(C.BlueLight, C.BlueDark, Color.White), + Red(Color(0xFFFF6C7E), Color(0xFFD6344E), Color.White), +} + +/** The pack's `PrimaryButton`: a filled, gradient, glowing action. */ +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: Tone = Tone.Green, + icon: ImageVector? = null, + height: Int = 44, +) { + val src = remember { MutableInteractionSource() } + val pressed by src.collectIsPressedAsState() + val shape = RoundedCornerShape((height / 2.6).dp) + Box( + modifier + .height(height.dp) + .shadow(if (pressed) 16.dp else 10.dp, shape, ambientColor = tone.bottom, spotColor = tone.bottom) + .clip(shape) + .background(Brush.verticalGradient(listOf(tone.top, tone.bottom))) + .background( + Brush.verticalGradient( + listOf(Color.White.copy(if (pressed) 0.28f else 0.20f), Color.Transparent, Color.Transparent), + ), + ) + .border(1.dp, Color.White.copy(0.28f), shape) + .clickable(src, null, onClick = onClick), + Alignment.Center, + ) { + Row( + Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) Icon(icon, null, Modifier.size(18.dp), tone.on) + Text(text, style = T.Button.copy(color = tone.on)) + } + } +} + +/** The pack's `GlassButton`: a translucent secondary action. `text == null` makes it square. */ +@Composable +fun GlassButton( + text: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + danger: Boolean = false, + active: Boolean = false, + height: Int = 40, +) { + val src = remember { MutableInteractionSource() } + val pressed by src.collectIsPressedAsState() + val tint = when { + danger -> C.Red + active -> C.Blue + else -> Color.White + } + val shape = RoundedCornerShape(CaramelDimens.RadiusButton) + val fg = if (danger) C.Red else C.Text + Box( + modifier + .height(height.dp) + .then(if (text == null) Modifier.width(height.dp) else Modifier) + .clip(shape) + .background(tint.copy(if (danger || active) 0.16f else if (pressed) 0.14f else 0.08f)) + .border(1.dp, tint.copy(if (danger || active) 0.45f else if (pressed) 0.28f else 0.17f), shape) + .clickable(src, null, onClick = onClick), + Alignment.Center, + ) { + Row( + Modifier.padding(horizontal = if (text == null) 0.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) Icon(icon, null, Modifier.size(17.dp), fg) + if (text != null) Text(text, style = T.Label.copy(color = fg)) + } + } +} + +/** The pack's `CaramelToggle`: a gradient track with a sliding thumb. */ +@Composable +fun CaramelToggle(checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { + val x by animateDpAsState(if (checked) 21.dp else 3.dp, spring(stiffness = 600f)) + val shape = RoundedCornerShape(50) + Box( + modifier + .size(40.dp, 22.dp) + .clip(shape) + .background( + if (checked) { + Brush.horizontalGradient(listOf(C.GreenLight, C.GreenDark)) + } else { + SolidColor(Color.White.copy(0.10f)) + }, + ) + .border(1.dp, if (checked) Color.Transparent else Color.White.copy(0.24f), shape) + .clickable(remember { MutableInteractionSource() }, null) { onChange(!checked) }, + ) { + Box( + Modifier + .offset(x, 3.dp) + .size(16.dp) + .shadow(3.dp, CircleShape) + .background(if (checked) Color.White else Color(0xFFBECAEB), CircleShape), + ) + } +} + +/** The pack's `CaramelCheckbox`. */ +@Composable +fun CaramelCheckbox(checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(5.dp) + Box( + modifier + .size(18.dp) + .clip(shape) + .background( + if (checked) { + Brush.verticalGradient(listOf(C.BlueLight, C.BlueDark)) + } else { + SolidColor(Color.White.copy(0.06f)) + }, + ) + .border(1.dp, if (checked) Color.Transparent else Color.White.copy(0.28f), shape) + .clickable(remember { MutableInteractionSource() }, null) { onChange(!checked) }, + Alignment.Center, + ) { + if (checked) Icon(Icons.Rounded.Check, null, Modifier.size(13.dp), Color.White) + } +} + +/** The pack's `CaramelSlider`: a gradient track and a white knob with an accent core. */ +@Composable +fun CaramelSlider( + value: Float, + onChange: (Float) -> Unit, + modifier: Modifier = Modifier, + accent: Color = C.Blue, + accentEnd: Color = Color(0xFF4ABEFF), +) { + var w by remember { mutableStateOf(1) } + Canvas( + modifier + .fillMaxWidth() + .height(22.dp) + .onSizeChanged { w = it.width.coerceAtLeast(1) } + .pointerInput(Unit) { + detectTapGestures { onChange((it.x / w).coerceIn(0f, 1f)) } + } + .pointerInput(Unit) { + detectDragGestures { c, _ -> onChange((c.position.x / w).coerceIn(0f, 1f)) } + }, + ) { + val cy = size.height / 2 + val th = 6.dp.toPx() + val kx = size.width * value + drawRoundRect(Color.White.copy(0.12f), Offset(0f, cy - th / 2), Size(size.width, th), CornerRadius(th)) + drawRoundRect(Brush.horizontalGradient(listOf(accent, accentEnd)), Offset(0f, cy - th / 2), Size(kx, th), CornerRadius(th)) + drawCircle(Color.Black.copy(0.35f), 9.dp.toPx(), Offset(kx, cy + 1.5.dp.toPx())) + drawCircle(Color.White, 8.dp.toPx(), Offset(kx, cy)) + drawCircle(accent, 3.dp.toPx(), Offset(kx, cy)) + } +} + +/** The pack's `CaramelTextField`, with a search leading icon for [SearchField]. */ +@Composable +fun CaramelTextField( + value: String, + onChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + leading: ImageVector? = null, +) { + var focused by remember { mutableStateOf(false) } + val border by animateColorAsState(if (focused) C.Blue.copy(0.8f) else Color.White.copy(0.16f)) + val shape = RoundedCornerShape(CaramelDimens.RadiusControl) + BasicTextField( + value, + onChange, + modifier + .height(38.dp) + .clip(shape) + .background(Color(0x6E040A1A)) + .border(1.dp, border, shape) + .onFocusChangedCompat { focused = it }, + singleLine = true, + textStyle = T.Label.copy(fontWeight = FontWeight.Normal), + cursorBrush = SolidColor(C.Blue), + decorationBox = { inner -> + Row( + Modifier + .padding(horizontal = 14.dp) + .fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (leading != null) Icon(leading, null, Modifier.size(16.dp), C.Text3) + Box(Modifier.weight(1f)) { + if (value.isEmpty()) Text(placeholder, style = T.Label.copy(color = C.Text3, fontWeight = FontWeight.Normal)) + inner() + } + } + }, + ) +} + +/** A search box, the pack's `SearchField`. */ +@Composable +fun SearchField( + value: String, + onChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, +) = CaramelTextField(value, onChange, modifier, placeholder, Icons.Rounded.Search) + +/** A title, an optional description and a trailing control, from the pack's `SettingRow`. */ +@Composable +fun SettingRow( + title: String, + desc: String? = null, + modifier: Modifier = Modifier, + control: @Composable () -> Unit, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = T.Label) + if (desc != null) Text(desc, style = T.Body) + } + control() + } +} + +/** A labelled slider whose value is shown in the accent colour, from the pack's `SliderRow`. */ +@Composable +fun SliderRow( + title: String, + valueText: String, + value: Float, + onChange: (Float) -> Unit, + accent: Color = C.Blue, + accentEnd: Color = Color(0xFF4ABEFF), +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row { + Text(title, style = T.Label, modifier = Modifier.weight(1f)) + Text(valueText, style = T.Label.copy(color = accent)) + } + CaramelSlider(value, onChange, accent = accent, accentEnd = accentEnd) + } +} + +/** Local sp helpers, so call sites can write `12.sp_` next to the pack's own literals. */ +val Int.sp_: androidx.compose.ui.unit.TextUnit + get() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp) + +val Double.sp_: androidx.compose.ui.unit.TextUnit + get() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp) diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt new file mode 100644 index 00000000..e864760b --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt @@ -0,0 +1,221 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties + +/** + * The Caramel dropdowns, ported from the pack's `components/Dropdowns.kt`. + * + *

A closed trigger plus a glass popup. Compose's `Popup` exists on Android, so the pack's + * mechanism ports unchanged; only the glass surface underneath it is the Android [GlassPanel] + * rather than the pack's Haze one. + */ + +/** A closed dropdown trigger plus a generic glass popup, anchored under the trigger. */ +@Composable +fun CaramelDropdown( + value: String, + modifier: Modifier = Modifier, + width: Int = 180, + panelWidth: Int = width, + panel: @Composable (close: () -> Unit) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box(modifier) { + CaramelDropdownField(value, open, Modifier.width(width.dp)) { open = !open } + if (open) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, 46), + onDismissRequest = { open = false }, + properties = PopupProperties(focusable = true), + ) { + GlassPanel(Modifier.width(panelWidth.dp), RoundedCornerShape(16.dp), popup = true, elevation = 32.dp) { + panel { open = false } + } + } + } + } +} + +/** The closed trigger itself: a value, a chevron, and an open/closed border. */ +@Composable +fun CaramelDropdownField(text: String, open: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val shape = RoundedCornerShape(CaramelDimens.RadiusControl) + Row( + modifier + .height(38.dp) + .clip(shape) + .background(Color.White.copy(if (open) 0.13f else 0.08f)) + .border(1.dp, if (open) C.Blue.copy(0.7f) else Color.White.copy(0.18f), shape) + .clickable(remember { MutableInteractionSource() }, null, onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text, style = T.Label.copy(fontWeight = FontWeight.Medium), modifier = Modifier.weight(1f)) + Icon(if (open) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, null, Modifier.size(18.dp), C.Text2) + } +} + +/** A plain string list dropdown. */ +@Composable +fun CaramelSimpleDropdown( + value: String, + options: List, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + width: Int = 190, +) = CaramelDropdown(value, modifier, width) { close -> + Column(Modifier.padding(8.dp)) { + options.forEach { o -> + Box( + Modifier + .fillMaxWidth() + .height(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(if (o == value) C.Blue.copy(0.24f) else Color.Transparent) + .clickable { onSelect(o); close() } + .padding(horizontal = 12.dp), + Alignment.CenterStart, + ) { Text(o, style = T.Label) } + } + } +} + +/** + * The version picker: a search box over a scrolling list, each row carrying its channel chip. + * + *

The pack filters a hardcoded list; this filters the list the activity supplies, so the picker + * can only ever show a version that is actually installed. + */ +@Composable +fun CaramelVersionDropdown( + versions: List, + selected: CaramelVersion?, + onSelect: (CaramelVersion) -> Unit, + modifier: Modifier = Modifier, +) = CaramelDropdown(selected?.id ?: "", modifier, width = 124, panelWidth = 300) { close -> + var q by remember { mutableStateOf("") } + val list = remember(q, versions) { versions.filter { it.id.contains(q.trim()) } } + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SearchField(q, { q = it }, "Search versions", Modifier.fillMaxWidth()) + LazyColumn(Modifier.heightIn(max = 252.dp)) { + items(list, key = { it.id }) { v -> + Row( + Modifier + .fillMaxWidth() + .height(36.dp) + .clip(RoundedCornerShape(9.dp)) + .background(if (v == selected) C.Blue.copy(0.26f) else Color.Transparent) + .clickable { onSelect(v); close() } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(v.id, style = T.Label, modifier = Modifier.weight(1f)) + ChannelLabel(v.channel.label, v.channel.color) + } + } + if (list.isEmpty()) item { Text("No versions match", style = T.Body, modifier = Modifier.padding(12.dp)) } + } + } +} + +/** The profile picker: an Open (green, unlocked) or Isolated (red, locked) instance profile. */ +@Composable +fun CaramelProfileDropdown( + selected: CaramelProfileMode, + onSelect: (CaramelProfileMode) -> Unit, + modifier: Modifier = Modifier, + width: Int = 328, +) { + var open by remember { mutableStateOf(false) } + Box(modifier) { + val shape = RoundedCornerShape(CaramelDimens.RadiusControl) + Row( + Modifier + .width(width.dp) + .height(38.dp) + .clip(shape) + .background(Color.White.copy(if (open) 0.13f else 0.08f)) + .border(1.dp, if (open) C.Blue.copy(0.7f) else Color.White.copy(0.18f), shape) + .clickable(remember { MutableInteractionSource() }, null) { open = !open } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Rounded.Folder, null, Modifier.size(17.dp), Color(0xFF50BEFF)) + Text( + if (selected == CaramelProfileMode.Open) "Open profile" else "Isolated profile", + style = T.Label.copy(fontWeight = FontWeight.Medium), + modifier = Modifier.weight(1f), + ) + Icon(Icons.Rounded.SwapHoriz, null, Modifier.size(16.dp), C.Text3) + } + if (open) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, 46), + onDismissRequest = { open = false }, + properties = PopupProperties(focusable = true), + ) { + GlassPanel(Modifier.width(width.dp), RoundedCornerShape(16.dp), popup = true, elevation = 32.dp) { + Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + CaramelProfileMode.entries.forEach { m -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CaramelDimens.RadiusControl)) + .background(if (m == selected) C.Blue.copy(0.22f) else Color.Transparent) + .clickable { onSelect(m); open = false } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(m.title, style = T.Label) + Text(m.desc, style = T.Body) + } + ProfileLabel(m.locked) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelGlass.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelGlass.kt new file mode 100644 index 00000000..d4020291 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelGlass.kt @@ -0,0 +1,163 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The Caramel glass surface, ported from the pack's `components/Glass.kt`. + * + *

The pack frosts its glass with Haze, a Compose Multiplatform backdrop-blur library that samples + * the window behind the card. Android's Compose has no equivalent (a `RenderEffect` can blur a + * view's own content, not what is behind it), so this uses the recipe the pack itself falls back to + * for ImGui: layered translucent fills that read as glass without a blur pass. + * + *

Two layers, and the light is what sells it: a cool blue-tinted fill at low alpha, then a + * vertical sheen that is brightest at the top and falls to nothing, so the card catches light from + * above. The hairline is a diagonal gradient rather than a flat colour, bright at the top-left and + * dim at the bottom-right, which is the cheap trick that stops a rounded rectangle reading as a + * flat outline. + */ +@Composable +fun GlassPanel( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(CaramelDimens.RadiusCard), + popup: Boolean = false, + elevation: Dp = 24.dp, + tint: Color = Color(0xFF9DB4FF).copy(alpha = if (popup) 0.10f else 0.06f), + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier + .shadow(elevation, shape, ambientColor = Color.Black, spotColor = Color(0xFF00010F)) + .clip(shape) + .background(C.Navy) + .background(tint) + .background( + Brush.verticalGradient( + listOf(Color.White.copy(0.075f), Color.White.copy(0.015f)), + ), + ) + .border( + 1.dp, + Brush.linearGradient( + listOf(Color.White.copy(0.30f), Color.White.copy(0.05f)), + ), + shape, + ), + content = content, + ) +} + +/** + * The navy wallpaper with soft colour pools, ported from the pack's `Backdrop`. + * + *

The pools exist so the glass has something to sit on: a flat fill under translucent cards + * leaves them looking like slightly lighter rectangles. Four radial gradients, drawn once on a + * Canvas, give the glass a light source to refract. + */ +@Composable +fun CaramelBackdrop(modifier: Modifier = Modifier) { + Canvas( + modifier + .fillMaxSize() + .background(Brush.verticalGradient(listOf(Color(0xFF070D22), Color(0xFF050918)))), + ) { + fun pool(x: Float, y: Float, r: Float, c: Color) = drawCircle( + Brush.radialGradient( + listOf(c, Color.Transparent), + Offset(size.width * x, size.height * y), + size.width * r, + ), + radius = size.width * r, + center = Offset(size.width * x, size.height * y), + ) + pool(0.18f, 0.20f, 0.42f, Color(0xFF3660FF).copy(0.55f)) + pool(0.82f, 0.10f, 0.32f, Color(0xFF28AAFF).copy(0.32f)) + pool(0.62f, 0.92f, 0.40f, Color(0xFF563CFF).copy(0.38f)) + pool(0.06f, 0.95f, 0.26f, Color(0xFFF5A742).copy(0.20f)) + } +} + +/** Dimmed scrim plus a centred glass modal with a scale/fade entrance, from the pack's `Modal`. */ +@Composable +fun CaramelModal( + visible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + AnimatedVisibility(visible, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier + .fillMaxSize() + .background(Color(0xAA030714)) + .clickable(remember { MutableInteractionSource() }, null, onClick = onDismiss), + Alignment.Center, + ) { + AnimatedVisibility( + visible, + enter = scaleIn(initialScale = 0.96f) + fadeIn(), + exit = scaleOut(targetScale = 0.97f) + fadeOut(), + ) { + GlassPanel( + modifier.clickable(remember { MutableInteractionSource() }, null) {}, + RoundedCornerShape(26.dp), + popup = true, + elevation = 40.dp, + content = content, + ) + } + } + } +} + +/** The pack's `Pill`: a rounded chip used by the title bar and the account row. */ +@Composable +fun CaramelPill( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit, +) { + val shape = RoundedCornerShape(50) + Row( + modifier + .height(26.dp) + .clip(shape) + .background(Color.White.copy(0.07f)) + .border(1.dp, Color.White.copy(0.16f), shape) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + content = content, + ) +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt new file mode 100644 index 00000000..bac7ec80 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt @@ -0,0 +1,176 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The Caramel label and chip set, ported from the pack's `components/Labels.kt`. + * + *

Labels carry status (Release/Beta, Locked/Unlocked, Equipped, a server's version range). The + * pack's rule is one tinted pill per status with a matching hairline, so a status reads the same + * wherever it appears. Keeping the rule in one function is what stops a "Beta" pill drifting to a + * different orange in one screen. + */ +@Composable +fun StatusLabel( + text: String, + color: Color, + modifier: Modifier = Modifier, + icon: ImageVector? = null, +) { + val shape = RoundedCornerShape(50) + Row( + modifier + .height(20.dp) + .clip(shape) + .background(color.copy(0.16f)) + .border(1.dp, color.copy(0.5f), shape) + .padding(horizontal = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (icon != null) Icon(icon, null, Modifier.size(11.dp), color) + Text(text, style = T.Chip.copy(color = color)) + } +} + +/** A "Release" (green) or "Beta" (orange) pill. */ +@Composable +fun ChannelLabel(label: String, color: Color, modifier: Modifier = Modifier) = StatusLabel(label, color, modifier) + +/** An "Unlocked" (green, open padlock) or "Locked" (red, closed padlock) pill. */ +@Composable +fun ProfileLabel(locked: Boolean, modifier: Modifier = Modifier) = StatusLabel( + if (locked) "Locked" else "Unlocked", + if (locked) C.Red else C.Green, + modifier, + if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen, +) + +/** A selectable filter chip; the selected one is a blue gradient. */ +@Composable +fun FilterChip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(50) + Box( + modifier + .height(26.dp) + .clip(shape) + .background( + if (selected) { + Brush.verticalGradient(listOf(C.BlueLight, C.BlueDark)) + } else { + SolidColor(Color.White.copy(0.07f)) + }, + ) + .border(1.dp, if (selected) Color.Transparent else Color.White.copy(0.16f), shape) + .clickable(remember { MutableInteractionSource() }, null, onClick = onClick) + .padding(horizontal = 13.dp), + Alignment.Center, + ) { + Text(text, style = T.Chip.copy(fontSize = 11.5.sp_, color = if (selected) Color.White else C.Text2)) + } +} + +/** The pack's `SegmentedTabs`: a pill row with a sliding blue selection. */ +@Composable +fun SegmentedTabs( + items: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(50) + Row( + modifier + .height(36.dp) + .clip(shape) + .background(Color.White.copy(0.06f)) + .border(1.dp, Color.White.copy(0.14f), shape) + .padding(3.dp), + ) { + items.forEachIndexed { i, t -> + Box( + Modifier + .width(96.dp) + .fillMaxHeight() + .clip(shape) + .background( + if (i == selected) { + Brush.verticalGradient(listOf(C.BlueLight, C.BlueDark)) + } else { + SolidColor(Color.Transparent) + }, + ) + .clickable(remember { MutableInteractionSource() }, null) { onSelect(i) }, + Alignment.Center, + ) { + Text( + t.uppercase(), + style = T.Chip.copy( + letterSpacing = androidx.compose.ui.unit.TextUnit(1f, androidx.compose.ui.unit.TextUnitType.Sp), + color = if (i == selected) Color.White else C.Text2, + ), + ) + } + } + } +} + +/** The account avatar slot. The profile picture is drawn by the caller over this. */ +@Composable +fun CaramelAvatar(size: Int = 36, online: Boolean = false, modifier: Modifier = Modifier) { + Box( + modifier + .size(size.dp) + .background(Color.White.copy(0.09f), CircleShape) + .border(if (online) 2.dp else 1.dp, if (online) C.Green else Color.White.copy(0.3f), CircleShape), + ) +} + +/** The server-icon slot used by Quick play and the Servers list. */ +@Composable +fun ServerIcon(color: Color, size: Int = 26, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape((size * 0.3f).dp) + Box( + modifier + .size(size.dp) + .clip(shape) + .background(color.copy(0.22f)) + .border(1.dp, color.copy(0.6f), shape), + Alignment.Center, + ) { + Box(Modifier.size((size * 0.4f).dp).background(color, RoundedCornerShape(2.dp))) + } +} + +/** A "1.21.132 – 26.62" supported-version label for a server. */ +@Composable +fun VersionRangeLabel(min: String, max: String, modifier: Modifier = Modifier) = + StatusLabel("$min – $max", Color(0xFF78A0FF), modifier) diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLoaderIcons.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLoaderIcons.kt new file mode 100644 index 00000000..50dd39f6 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLoaderIcons.kt @@ -0,0 +1,112 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +/** + * The three loader glyphs, ported from the pack's `components/LoaderIcons.kt`: the Bedrock block, + * the Rust cog and the Lightning bolt. Each is drawn rather than shipped as an asset so it scales + * and recolours without a density bucket. + */ +@Composable +fun LoaderGlyph(loader: CaramelLoader, modifier: Modifier = Modifier.size(28.dp)) { + when (loader) { + CaramelLoader.Bedrock -> Canvas(modifier) { + val n = 12 + val px = size.width / n + val rnd = Random(7) + for (i in 0 until n) { + for (j in 0 until n) { + val v = if (rnd.nextFloat() > 0.15f) listOf(52, 64, 78, 92, 108, 124).random(rnd) else listOf(28, 34).random(rnd) + drawRect(Color(v, v, v + 6), Offset(i * px, j * px), Size(px + 0.5f, px + 0.5f)) + } + } + drawRoundRect(Color(0xFF14141A), Offset.Zero, size, CornerRadius(2.dp.toPx()), Stroke(1.5.dp.toPx())) + } + CaramelLoader.Rust -> Box(modifier, Alignment.Center) { + Canvas(Modifier.size(28.dp)) { + val c = Offset(size.width / 2, size.height / 2) + val r = size.width * 0.36f + val rust = Color(0xFFDEA584) + for (i in 0 until 12) { + val a = i * PI / 6 + drawCircle( + rust, + size.width * 0.085f, + Offset(c.x + (r + size.width * 0.1f) * cos(a).toFloat(), c.y + (r + size.width * 0.1f) * sin(a).toFloat()), + ) + } + drawCircle(rust, r + size.width * 0.04f, c) + drawCircle(Color(0xFF221612), r * 0.72f, c) + } + Text("R", style = T.Title.copy(color = Color(0xFFDEA584), fontSize = 12.sp_)) + } + CaramelLoader.Lightning -> Canvas(modifier) { + val w = size.width + val h = size.height + val p = Path().apply { + moveTo(0.56f * w, 0.06f * h) + lineTo(0.19f * w, 0.56f * h) + lineTo(0.46f * w, 0.56f * h) + lineTo(0.40f * w, 0.94f * h) + lineTo(0.81f * w, 0.40f * h) + lineTo(0.54f * w, 0.40f * h) + close() + } + drawPath(p, C.Yellow) + } + } +} + +/** A selectable loader tile: the glyph in a rounded square, with a green tick when selected. */ +@Composable +fun LoaderTile(loader: CaramelLoader, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(CaramelDimens.RadiusControl) + Box(modifier.size(44.dp)) { + Box( + Modifier + .size(44.dp) + .background(Color.White.copy(if (selected) 0.12f else 0.07f), shape) + .border(if (selected) 1.6.dp else 1.dp, if (selected) C.Green else Color.White.copy(0.14f), shape) + .clickable(remember { MutableInteractionSource() }, null, onClick = onClick), + Alignment.Center, + ) { LoaderGlyph(loader, Modifier.size(28.dp)) } + if (selected) { + Box( + Modifier + .offset((-5).dp, (-5).dp) + .size(14.dp) + .background(C.Green, CircleShape) + .border(1.5.dp, C.Navy, CircleShape), + Alignment.Center, + ) { Icon(Icons.Rounded.Check, null, Modifier.size(9.dp), C.OnGreen) } + } + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelModels.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelModels.kt new file mode 100644 index 00000000..4622d1d4 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelModels.kt @@ -0,0 +1,120 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * The launcher's plain-data shapes, ported from the pack's `data/Models.kt`. + * + *

The pack ships its own `Versions`, `ExampleMods` and `ExampleServers` here. Those are + * stand-ins for a demo and are not carried over: the honesty rule forbids drawing a number or a + * name the client has not actually measured or fetched, so every list this file describes is + * filled by the activity from a real manager. What stays is the shape and the enumerations, so the + * screens are a pure function of a small immutable value and can be tested on the JVM. + */ + +/** A version's release channel, which is what the green/orange chip on a version row shows. */ +enum class CaramelChannel(val label: String, val color: Color) { + Release("Release", CaramelColors.Green), + Beta("Beta", CaramelColors.Orange), +} + +/** One selectable game version. [beta] drives the channel chip. */ +data class CaramelVersion(val id: String, val beta: Boolean) { + val channel: CaramelChannel get() = if (beta) CaramelChannel.Beta else CaramelChannel.Release +} + +/** The three loaders the pack's launch menu offers. */ +enum class CaramelLoader(val title: String) { + Bedrock("Minecraft Bedrock"), + Rust("Rust"), + Lightning("Lightning"), +} + +/** The instance profile mode: a shared "Open" profile or an isolated one. */ +enum class CaramelProfileMode(val title: String, val desc: String, val label: String, val locked: Boolean) { + Open("Open Instance Profile", "Shares worlds and settings", "Unlocked", false), + Isolated("Isolated Instance Profile", "Separate files and mods", "Locked", true), +} + +/** The six launcher tabs the pack's sidebar carries. */ +enum class CaramelTab { Home, Mods, Servers, Cosmetics, News, Store } + +/** One mod row: the fields the Mods grid and the instance-settings list draw. */ +data class CaramelModInfo( + val name: String, + val author: String, + val version: String, + val description: String, + val enabled: Boolean, +) + +/** One saved server: what the Servers tab draws. [online] and [ping] are only set when measured. */ +data class CaramelServerInfo( + val name: String, + val address: String, + val online: Int?, + val ping: Int?, + val minVersion: String, + val maxVersion: String, +) + +/** One cosmetic tile: the Cosmetics grid draws these from the real catalogue. */ +data class CaramelCosmeticInfo( + val name: String, + val kind: String, + val equipped: Boolean, + val owned: Boolean, + val favourite: Boolean, +) + +/** One news/changelog entry: the News tab draws these from the real changelog. */ +data class CaramelNewsInfo( + val title: String, + val summary: String, + val meta: String, + val featured: Boolean, +) + +/** + * The launcher's whole mutable state, as the pack's `LauncherState` is. + * + *

Every list defaults to empty and every count to zero: the screens then draw their empty state + * rather than a fabricated row. The activity fills them from the live managers. + */ +class CaramelLauncherState { + var tab by mutableStateOf(CaramelTab.Home) + + var versions by mutableStateOf>(emptyList()) + var version by mutableStateOf(null) + var loader by mutableStateOf(CaramelLoader.Bedrock) + var profile by mutableStateOf(CaramelProfileMode.Isolated) + + var instanceName by mutableStateOf("") + var accountName by mutableStateOf("") + var signedIn by mutableStateOf(false) + var runningInstances by mutableStateOf(0) + var launchEnabled by mutableStateOf(false) + var launching by mutableStateOf(false) + + var mods by mutableStateOf>(emptyList()) + var servers by mutableStateOf>(emptyList()) + var cosmetics by mutableStateOf>(emptyList()) + var news by mutableStateOf>(emptyList()) + + var launchMenu by mutableStateOf(false) + var instanceSettings by mutableStateOf(false) + var accountMenu by mutableStateOf(false) + var addFriend by mutableStateOf(false) + var launcherSettings by mutableStateOf(false) + var appMenu by mutableStateOf(false) + var screenshots by mutableStateOf(false) + var friendsAll by mutableStateOf(false) + var addAccount by mutableStateOf(false) + var accountSettings by mutableStateOf(false) + + /** The selected mod row, or null when the detail modal is closed. */ + var selectedMod by mutableStateOf(null) +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelTheme.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelTheme.kt new file mode 100644 index 00000000..0e12b2a7 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelTheme.kt @@ -0,0 +1,127 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.chimeramc.client.R + +/** + * The Caramel Client design tokens, ported from the `CaramelClient-UI-pack` Compose launcher. + * + *

The pack's `theme/Theme.kt` defines its palette as a flat `object C`. That is kept here rather + * than folded into Material's `ColorScheme`, because most of what this UI draws (hairlines, fills, + * glass sheens, the caramel accent) has no Material slot. The Material theme is still installed so + * `Text`/`Icon` defaults resolve; the tokens below are what the Caramel surfaces actually read. + * + *

Colour values are byte-for-byte the pack's. Changing one here without changing the pack's + * `Theme.kt` and `CaramelTheme.cpp` (the ImGui twin) is how the launcher and the in-game menu drift + * apart, so the three are meant to move together. + */ +object CaramelColors { + val Abyss = Color(0xFF060B1A) + val Navy = Color(0xFF0A1330) + val DeepBlue = Color(0xFF0F1B44) + val Blue = Color(0xFF4A78FF) + val BlueLight = Color(0xFF628CFF) + val BlueDark = Color(0xFF3458E6) + val Caramel = Color(0xFFF5A742) + val CaramelDark = Color(0xFFDE8624) + val Green = Color(0xFF2FD36B) + val GreenLight = Color(0xFF3EE27A) + val GreenDark = Color(0xFF18A050) + val Red = Color(0xFFFF5468) + val Orange = Color(0xFFFF9F2E) + val Yellow = Color(0xFFFFD040) + val Text = Color(0xFFEAF0FF) + val Text2 = Color(0xFF9AA8D0) + val Text3 = Color(0xFF66749E) + val OnGreen = Color(0xFF062412) + val Hairline = Color.White.copy(alpha = 0.14f) + val Fill = Color.White.copy(alpha = 0.07f) + val FillStrong = Color.White.copy(alpha = 0.12f) +} + +/** Short alias, matching the pack's `C`. */ +typealias C = CaramelColors + +/** + * The pack's type scale: Poppins for display and titles, Inter for controls and body. + * + *

Both families ship as variable fonts in `res/font`, so one file per family carries every + * weight and the weight is selected by axis rather than by a second file. + */ +private val Poppins = FontFamily( + Font(R.font.poppins, FontWeight.Normal), + Font(R.font.poppins, FontWeight.Medium), + Font(R.font.poppins, FontWeight.Bold), +) + +private val Inter = FontFamily( + Font(R.font.inter, FontWeight.Normal), + Font(R.font.inter, FontWeight.Medium), + Font(R.font.inter, FontWeight.SemiBold), + Font(R.font.inter, FontWeight.Bold), +) + +/** Short alias, matching the pack's `T`. */ +object CaramelType { + val Display = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = C.Text) + val Title = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 16.sp, color = C.Text) + val Subtitle = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = C.Text) + val Button = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = C.Text) + val Label = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = C.Text) + val Body = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, color = C.Text2) + val Caption = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 11.sp, color = C.Text3) + val Chip = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.5.sp) +} + +typealias T = CaramelType + +/** + * Installs the Caramel palette into Material so `Text`, `Icon` and the Material defaults resolve to + * Caramel colours. + * + *

Most Caramel surfaces read [CaramelColors] directly — they draw things Material's scheme has no + * slot for. This wrapper exists so a Material component dropped into the tree (a + * `CircularProgressIndicator`, a ripple) does not fall back to the default purple. + */ +@Composable +fun CaramelTheme(content: @Composable () -> Unit) { + androidx.compose.material3.MaterialTheme( + colorScheme = androidx.compose.material3.darkColorScheme( + primary = C.Blue, + onPrimary = Color.White, + primaryContainer = C.DeepBlue, + onPrimaryContainer = C.Text, + secondary = C.Caramel, + onSecondary = Color.White, + background = C.Abyss, + onBackground = C.Text, + surface = C.Navy, + onSurface = C.Text, + surfaceVariant = C.DeepBlue, + onSurfaceVariant = C.Text2, + outline = C.Hairline, + error = C.Red, + onError = Color.White, + ), + content = content, + ) +} + +/** The pack's spacing and radius scale. */ +object CaramelDimens { + val RadiusCard = 20.dp + val RadiusHero = 24.dp + val RadiusPanel = 18.dp + val RadiusControl = 11.dp + val RadiusButton = 12.dp + val PagePad = 14.dp + val Gap = 16.dp + val GapLarge = 20.dp +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt new file mode 100644 index 00000000..35956d01 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt @@ -0,0 +1,7 @@ +package org.chimeramc.client.ui.compose.caramel + +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged + +/** The pack's `onFocusChangedCompat`, used by [CaramelTextField]. */ +fun Modifier.onFocusChangedCompat(block: (Boolean) -> Unit): Modifier = onFocusChanged { block(it.isFocused) } diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt new file mode 100644 index 00000000..046479f3 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt @@ -0,0 +1,231 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.chimeramc.client.ui.compose.caramel.C +import org.chimeramc.client.ui.compose.caramel.CaramelCosmeticInfo +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.FilterChip +import org.chimeramc.client.ui.compose.caramel.GlassButton +import org.chimeramc.client.ui.compose.caramel.GlassPanel +import org.chimeramc.client.ui.compose.caramel.PrimaryButton +import org.chimeramc.client.ui.compose.caramel.StatusLabel +import org.chimeramc.client.ui.compose.caramel.T +import org.chimeramc.client.ui.compose.caramel.sp_ + +private val cosAccents = listOf(C.Blue, C.Caramel, C.Green, Color(0xFF966EFF)) + +/** + * The Cosmetics screen, ported from the pack's `CosmeticsScreen`. + * + *

The pack reserves a white rounded square for the 3D skin viewer; on Android the client already + * has a software skin renderer, so the slot is filled by the host through [skinViewer]. When the + * host passes nothing, the slot shows its empty label rather than a fake model. + */ +@Composable +fun CaramelCosmeticsScreen( + state: CaramelLauncherState, + onToggleFavourite: (CaramelCosmeticInfo) -> Unit, + onEquip: (CaramelCosmeticInfo) -> Unit, + onResetView: () -> Unit, + skinViewer: @Composable (Modifier) -> Unit = { EmptySkinSlot(it) }, +) { + var chip by remember { mutableStateOf(0) } + CaramelPage("Cosmetics", "Capes, wings, hats and more") { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + GlassPanel(Modifier.width(320.dp).fillMaxHeight(), RoundedCornerShape(24.dp)) { + Column( + Modifier.fillMaxSize().padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.fillMaxWidth().weight(1f).background(Color.White, RoundedCornerShape(22.dp)), + Alignment.Center, + ) { skinViewer(Modifier.fillMaxSize().padding(8.dp)) } + GlassButton("Save outfit", {}, Modifier.fillMaxWidth(), icon = Icons.Rounded.Check) + GlassButton("Reset view", onResetView, Modifier.fillMaxWidth(), icon = Icons.Rounded.SwapHoriz) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + listOf("All", "Capes", "Wings", "Hats", "Pets").forEachIndexed { i, t -> FilterChip(t, chip == i, { chip = i }) } + FilterChip("Favorites", chip == 5, { chip = 5 }) + } + if (state.cosmetics.isEmpty()) { + EmptyState("No cosmetics yet", "Capes and hats you own appear here.", Modifier.fillMaxWidth().weight(1f)) + } else { + LazyVerticalGrid( + GridCells.Adaptive(190.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.cosmetics, key = { it.name }) { c -> + CosmeticTile(c, { onToggleFavourite(c) }, { onEquip(c) }) + } + } + } + } + } + } +} + +@Composable +internal fun EmptySkinSlot(modifier: Modifier) { + Box(modifier, Alignment.Center) { + Text( + "3D skin preview", + style = T.Title.copy(color = Color(0xFF1C2854), fontSize = 15.sp_, textAlign = TextAlign.Center), + modifier = Modifier.width(190.dp), + ) + } +} + +@Composable +private fun CosmeticTile(c: CaramelCosmeticInfo, onFav: () -> Unit, onEquip: () -> Unit) { + val accent = cosAccents[(c.name.hashCode() and 0x7fffffff) % 4] + GlassPanel(Modifier.fillMaxWidth().height(188.dp), RoundedCornerShape(18.dp), elevation = 6.dp) { + Column(Modifier.padding(8.dp)) { + Box( + Modifier.fillMaxWidth().height(112.dp) + .background(Brush.verticalGradient(listOf(accent.copy(0.32f), accent.copy(0.05f))), RoundedCornerShape(12.dp)), + Alignment.Center, + ) { + Box(Modifier.size(40.dp, 68.dp).background(accent, RoundedCornerShape(4.dp))) + Box( + Modifier.align(Alignment.TopEnd).padding(8.dp).size(28.dp).clip(CircleShape) + .background(Color(0x96081024)) + .border(1.dp, if (c.favourite) C.Caramel else Color.White.copy(0.25f), CircleShape) + .clickable(remember { MutableInteractionSource() }, null, onClick = onFav), + Alignment.Center, + ) { + Icon( + if (c.favourite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + null, Modifier.size(16.dp), if (c.favourite) C.Caramel else C.Text, + ) + } + } + Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(c.name, style = T.Label, maxLines = 1) + if (c.equipped) { + StatusLabel("Equipped", C.Green) + } else if (c.owned) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Owned", style = T.Body, modifier = Modifier.weight(1f)) + GlassButton("Equip", onEquip, height = 28) + } + } else { + StatusLabel("Locked", C.Caramel) + } + } + } + } +} + +/** The News screen: a featured post over a side list. */ +@Composable +fun CaramelNewsScreen(state: CaramelLauncherState) { + CaramelPage("News", "Updates, events and community posts") { + Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + val featured = state.news.firstOrNull { it.featured } ?: state.news.firstOrNull() + if (featured == null) { + EmptyState("No news yet", "Posts appear here once the changelog loads.", Modifier.fillMaxSize()) + } else { + GlassPanel(Modifier.weight(1.3f).fillMaxHeight(), RoundedCornerShape(24.dp)) { + Column(Modifier.padding(6.dp)) { + Box( + Modifier.fillMaxWidth().height(260.dp) + .background(Brush.verticalGradient(listOf(Color(0xFF5A2818), Color(0xFFF5963C))), RoundedCornerShape(18.dp)), + ) + Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + StatusLabel("Featured", C.Caramel) + Text(featured.title, style = T.Display) + Text(featured.summary, style = T.Body.copy(fontSize = 13.sp_)) + Text(featured.meta, style = T.Label.copy(color = C.Caramel)) + } + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { + state.news.drop(1).take(3).forEachIndexed { i, n -> + GlassPanel(Modifier.fillMaxWidth().height(130.dp), RoundedCornerShape(20.dp), elevation = 6.dp) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Box( + Modifier.size(150.dp, 106.dp) + .background(Brush.verticalGradient(listOf(cosAccents[i % 4].copy(0.5f), cosAccents[i % 4].copy(0.1f))), RoundedCornerShape(12.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(n.title, style = T.Subtitle.copy(fontSize = 15.sp_), maxLines = 2) + Text(n.summary, style = T.Body, maxLines = 2) + Text(n.meta, style = T.Caption) + } + } + } + } + } + } + } + } +} + +/** The Store screen: a Caramel+ banner over the bundle grid. */ +@Composable +fun CaramelStoreScreen(state: CaramelLauncherState) { + CaramelPage("Store", "Cosmetics, bundles and Caramel+") { + GlassPanel(Modifier.fillMaxWidth().height(160.dp), RoundedCornerShape(24.dp)) { + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(listOf(C.Caramel.copy(0.3f), C.Blue.copy(0.08f))))) + Row(Modifier.fillMaxSize().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Caramel+", style = T.Display.copy(fontSize = 28.sp_)) + Text( + "Support the client and unlock cosmetics, themes and early builds.", + style = T.Body.copy(fontSize = 13.5.sp_), + ) + } + PrimaryButton("Learn more", {}, Modifier.width(200.dp)) + } + } + EmptyState( + "Store coming soon", + "Bundles will appear here once the store backend is live.", + Modifier.fillMaxWidth().weight(1f), + ) + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt new file mode 100644 index 00000000..75836374 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt @@ -0,0 +1,276 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.PhotoCamera +import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.chimeramc.client.R +import org.chimeramc.client.ui.compose.caramel.C +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.GlassButton +import org.chimeramc.client.ui.compose.caramel.GlassPanel +import org.chimeramc.client.ui.compose.caramel.PrimaryButton +import org.chimeramc.client.ui.compose.caramel.T +import org.chimeramc.client.ui.compose.caramel.Tone +import org.chimeramc.client.ui.compose.caramel.sp_ + +/** + * The Home screen, ported from the pack's `launcher/Home.kt`. + * + *

The pack's hero, launch split button, latest-news row, friends card and Caramel+ promo are all + * here. The two cards whose content is data (news, the version line) read [CaramelLauncherState] + * rather than the pack's literals, so nothing drawn is a value the client did not measure. + */ +@Composable +fun CaramelHomeScreen( + state: CaramelLauncherState, + onLaunch: () -> Unit, + onOpenContent: () -> Unit, +) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(20.dp)) { + Hero(state, onLaunch, onOpenContent, Modifier.fillMaxWidth().height(320.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Latest news", style = T.Title, modifier = Modifier.weight(1f)) + Text("View all news", style = T.Label.copy(color = C.Text2, fontSize = 11.5.sp_)) + } + Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val cards = state.news.take(3) + if (cards.isEmpty()) { + EmptyCard("No news yet", "New posts appear here once the changelog loads.", Modifier.weight(1f)) + } else { + cards.forEachIndexed { i, n -> + NewsCard(n.title, n.meta, newsArt(i), Modifier.weight(1f)) + } + } + } + } + Column(Modifier.width(374.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(20.dp)) { + FriendsCard(state, Modifier.fillMaxWidth().height(320.dp)) + PromoCard(Modifier.fillMaxWidth().weight(1f)) + } + } +} + +/** The three news-card art gradients, matching the pack's amber / blue / pink rotation. */ +private fun newsArt(i: Int): List = when (i % 3) { + 0 -> listOf(Color(0xFF5A2818), Color(0xFFF5963C)) + 1 -> listOf(Color(0xFF182660), Color(0xFF4A5ABE)) + else -> listOf(Color(0xFF541C4C), Color(0xFFF06EA0)) +} + +@Composable +private fun Hero(state: CaramelLauncherState, onLaunch: () -> Unit, onOpenContent: () -> Unit, modifier: Modifier) { + GlassPanel(modifier, RoundedCornerShape(24.dp)) { + Canvas(Modifier.matchParentSize()) { + drawCircle( + Brush.radialGradient( + listOf(C.Blue.copy(0.28f), Color.Transparent), + Offset(size.width * 0.25f, size.height * 0.5f), + size.width * 0.5f, + ), + center = Offset(size.width * 0.25f, size.height * 0.5f), + radius = size.width * 0.5f, + ) + } + Image(painterResource(R.drawable.ic_caramel_logo), null, Modifier.align(Alignment.CenterStart).padding(start = 24.dp).size(210.dp)) + Column( + Modifier.align(Alignment.Center).padding(start = 130.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Ready when you are", style = T.Body) + LaunchSplitButton(state, onLaunch) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GlassButton("Profiles", { state.launchMenu = true }, icon = Icons.Rounded.Folder, height = 32) + GlassButton("Instances", { state.instanceSettings = true }, icon = Icons.Rounded.Tune, height = 32) + GlassButton("Content", onOpenContent, icon = Icons.Rounded.PhotoCamera, height = 32) + } + val line = if (state.instanceName.isNotEmpty()) state.instanceName else "No instance selected" + Text(line, style = T.Caption) + } + } +} + +/** LAUNCH GAME with a split chevron that opens the launch menu, from the pack's `LaunchSplitButton`. */ +@Composable +private fun LaunchSplitButton(state: CaramelLauncherState, onLaunch: () -> Unit) { + val shape = RoundedCornerShape(20.dp) + Row( + Modifier.width(340.dp).height(72.dp).clip(shape) + .background(Brush.verticalGradient(listOf(C.GreenLight, C.GreenDark))) + .background(Brush.verticalGradient(listOf(Color.White.copy(0.22f), Color.Transparent, Color.Transparent))) + .border(1.dp, Color.White.copy(0.3f), shape), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.weight(1f).fillMaxHeight() + .clickable(enabled = state.launchEnabled && !state.launching, onClick = onLaunch), + Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + if (state.launching) { + CircularProgressIndicator(color = C.OnGreen, strokeWidth = 2.dp, modifier = Modifier.size(22.dp)) + } else { + Text("LAUNCH GAME", style = T.Display.copy(fontSize = 19.sp_, color = C.OnGreen)) + val sub = state.version?.id?.let { "Caramel Client $it" } ?: "No version installed" + Text(sub, style = T.Chip.copy(color = C.OnGreen.copy(0.85f))) + } + } + } + Box(Modifier.width(1.dp).height(48.dp).background(C.OnGreen.copy(0.3f))) + Box( + Modifier.width(52.dp).fillMaxHeight() + .clickable(remember { MutableInteractionSource() }, null) { state.launchMenu = !state.launchMenu }, + Alignment.Center, + ) { + Icon( + if (state.launchMenu) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, + null, Modifier.size(24.dp), C.OnGreen, + ) + } + } +} + +@Composable +private fun NewsCard(title: String, meta: String, art: List, modifier: Modifier) { + GlassPanel(modifier, RoundedCornerShape(18.dp), elevation = 10.dp) { + Column(Modifier.padding(6.dp)) { + Box( + Modifier.fillMaxWidth().height(150.dp).clip(RoundedCornerShape(13.dp)) + .background(Brush.verticalGradient(art)), + ) { + Canvas(Modifier.fillMaxSize()) { + val u = 12.dp.toPx() + var x = 0f + var i = 0 + while (x < size.width) { + val h = size.height * 0.36f + (kotlin.math.sin(i * 0.5) * 18.dp.toPx()).toFloat() + drawRect(Color.Black.copy(0.45f), Offset(x, size.height - h), androidx.compose.ui.geometry.Size(u, h)) + drawRect(Color.White.copy(0.25f), Offset(x, size.height - h), androidx.compose.ui.geometry.Size(u, 3.dp.toPx())) + x += u + i++ + } + } + } + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = T.Subtitle.copy(fontSize = 13.5.sp_), maxLines = 2) + Text(meta, style = T.Body, maxLines = 2) + } + } + } +} + +/** The empty state a card shows when its data has not loaded, instead of a fabricated row. */ +@Composable +private fun EmptyCard(title: String, body: String, modifier: Modifier) { + GlassPanel(modifier, RoundedCornerShape(18.dp), elevation = 10.dp) { + Column( + Modifier.fillMaxSize().padding(18.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = T.Subtitle.copy(fontSize = 13.5.sp_)) + Spacer(Modifier.height(6.dp)) + Text(body, style = T.Body, textAlign = TextAlign.Center) + } + } +} + +@Composable +private fun FriendsCard(state: CaramelLauncherState, modifier: Modifier) { + GlassPanel(modifier, RoundedCornerShape(24.dp)) { + Column(Modifier.fillMaxSize().padding(22.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Friends", style = T.Title, modifier = Modifier.weight(1f)) + Text( + "View all", + style = T.Label.copy(color = C.Text2, fontSize = 11.5.sp_), + modifier = Modifier.clickable(remember { MutableInteractionSource() }, null) { state.friendsAll = true }, + ) + } + Column( + Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.size(60.dp).clip(CircleShape).background(Color.White.copy(0.07f)) + .border(1.dp, Color.White.copy(0.17f), CircleShape), + Alignment.Center, + ) { Icon(Icons.Rounded.Group, null, Modifier.size(28.dp), C.Text2) } + Spacer(Modifier.height(16.dp)) + Text( + "Chat with friends, send game invites, and more.", + style = T.Body.copy(fontSize = 12.5.sp_, textAlign = TextAlign.Center), + modifier = Modifier.width(200.dp), + ) + Spacer(Modifier.height(16.dp)) + GlassButton("Add your first friend", { state.addFriend = true }, icon = Icons.Rounded.Add) + } + } + } +} + +@Composable +private fun PromoCard(modifier: Modifier) { + GlassPanel(modifier, RoundedCornerShape(24.dp)) { + Box(Modifier.matchParentSize().background(Brush.verticalGradient(listOf(C.Caramel.copy(0.26f), Color.Transparent)))) + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painterResource(R.drawable.ic_caramel_logo), null, Modifier.size(130.dp)) + Text("Become a", style = T.Title.copy(fontSize = 19.sp_)) + Text("Caramel+", style = T.Display.copy(fontSize = 25.sp_, color = C.Caramel)) + Spacer(Modifier.height(8.dp)) + Text( + "Support the client and unlock cosmetics, themes and early builds.", + style = T.Body.copy(fontSize = 12.5.sp_, textAlign = TextAlign.Center), + modifier = Modifier.width(310.dp), + ) + Spacer(Modifier.weight(1f)) + PrimaryButton("Get Caramel+", {}, Modifier.fillMaxWidth(), Tone.Caramel) + } + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherHost.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherHost.kt new file mode 100644 index 00000000..b13b0315 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherHost.kt @@ -0,0 +1,69 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.CaramelModInfo +import org.chimeramc.client.ui.compose.caramel.CaramelTheme +import org.chimeramc.client.ui.compose.caramel.CaramelCosmeticInfo +import org.chimeramc.client.ui.compose.caramel.CaramelServerInfo + +/** + * The Java-facing entry point for the Caramel launcher UI. + * + *

A `@Composable` function cannot be called from Java: the compiler threads a `Composer` + * parameter and a content lambda whose type Java cannot name. This host is the seam — the activity + * builds the state and hands over plain callbacks, and everything Compose-shaped stays on the + * Kotlin side. It mirrors the pack's `Main.kt` entry, minus the desktop `Window`. + */ +object CaramelLauncherHost { + + /** The whole launcher state, as a Kotlin [MutableState] so a set recomposes. */ + @JvmStatic + fun state(): MutableState = mutableStateOf(CaramelLauncherState()) + + /** Everything the launcher can ask the activity to do. */ + interface Actions { + fun launch() + fun selectVersion(id: String) + fun toggleMod(mod: CaramelModInfo, on: Boolean) + fun equipCosmetic(cosmetic: CaramelCosmeticInfo) + fun toggleFavourite(cosmetic: CaramelCosmeticInfo) + fun joinServer(server: CaramelServerInfo) + fun openModsFolder() + fun openContent() + fun openSettings() + fun openAccounts() + fun quit() + } + + /** + * Mounts the launcher into [composeView], wrapped in the Caramel theme. + * + *

The skin preview slot is left to [skinViewerProvider]: a `@Composable` lambda cannot be + * passed from Java, so the activity opts in by setting the provider from Kotlin code (or not at + * all, in which case the Cosmetics screen shows its empty slot). + */ + @JvmStatic + fun setContent( + composeView: ComposeView, + state: MutableState, + actions: Actions, + ) { + composeView.setContent { + CaramelTheme { + CaramelLauncherScreen( + state = state.value, + actions = actions, + skinViewer = skinViewerProvider ?: { EmptySkinSlot(it) }, + ) + } + } + } + + /** The Cosmetics skin-preview slot; a Kotlin caller may replace it. */ + @JvmStatic + var skinViewerProvider: (@Composable (androidx.compose.ui.Modifier) -> Unit)? = null +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherScreen.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherScreen.kt new file mode 100644 index 00000000..56f576a2 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherScreen.kt @@ -0,0 +1,49 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.CaramelTab + +/** + * The whole Caramel launcher, ported from the pack's `CaramelApp` in `launcher/Shell.kt`. + * + *

The frame (backdrop, title bar, sidebar) hosts the active tab, and the overlays and modals sit + * above it. This is the single composable the activity mounts through [CaramelLauncherHost]. + */ +@Composable +fun CaramelLauncherScreen( + state: CaramelLauncherState, + actions: CaramelLauncherHost.Actions, + skinViewer: @Composable (Modifier) -> Unit, +) { + Box(Modifier.fillMaxSize()) { + CaramelLauncherFrame(state) { contentModifier -> + Box(contentModifier) { + when (state.tab) { + CaramelTab.Home -> CaramelHomeScreen(state, actions::launch, actions::openContent) + CaramelTab.Mods -> CaramelModsScreen( + state, + onToggle = actions::toggleMod, + onOpenFolder = actions::openModsFolder, + ) + CaramelTab.Servers -> CaramelServersScreen(state, actions::joinServer) + CaramelTab.Cosmetics -> CaramelCosmeticsScreen( + state, + onToggleFavourite = actions::toggleFavourite, + onEquip = actions::equipCosmetic, + onResetView = {}, + skinViewer = skinViewer, + ) + CaramelTab.News -> CaramelNewsScreen(state) + CaramelTab.Store -> CaramelStoreScreen(state) + } + } + } + CaramelLaunchMenuOverlay(state, actions::launch) + CaramelModDetailModal(state, actions::toggleMod) + CaramelAppMenuOverlay(state, actions::quit) + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt new file mode 100644 index 00000000..a9d2d89d --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt @@ -0,0 +1,227 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Dashboard +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Place +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.chimeramc.client.ui.compose.caramel.C +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.CaramelModInfo +import org.chimeramc.client.ui.compose.caramel.CaramelServerInfo +import org.chimeramc.client.ui.compose.caramel.CaramelToggle +import org.chimeramc.client.ui.compose.caramel.FilterChip +import org.chimeramc.client.ui.compose.caramel.GlassButton +import org.chimeramc.client.ui.compose.caramel.GlassPanel +import org.chimeramc.client.ui.compose.caramel.PrimaryButton +import org.chimeramc.client.ui.compose.caramel.SearchField +import org.chimeramc.client.ui.compose.caramel.ServerIcon +import org.chimeramc.client.ui.compose.caramel.StatusLabel +import org.chimeramc.client.ui.compose.caramel.T +import org.chimeramc.client.ui.compose.caramel.VersionRangeLabel +import org.chimeramc.client.ui.compose.caramel.sp_ + +private val accents = listOf(C.Blue, C.Caramel, C.Green, Color(0xFF966EFF)) + +/** The pack's `Page` header: a display title, a subtitle and optional trailing actions. */ +@Composable +internal fun CaramelPage( + title: String, + sub: String, + actions: @Composable () -> Unit = {}, + body: @Composable ColumnScope.() -> Unit, +) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(title, style = T.Display) + Text(sub, style = T.Body.copy(fontSize = 12.5.sp_)) + } + actions() + } + body() + } +} + +@Composable +private fun IconTile(icon: ImageVector, color: Color, size: Int = 48) = + Box( + Modifier.size(size.dp).background(color.copy(0.2f), RoundedCornerShape(14.dp)) + .border(1.dp, color.copy(0.55f), RoundedCornerShape(14.dp)), + Alignment.Center, + ) { Icon(icon, null, Modifier.size((size / 2).dp), color) } + +/** The Mods screen: search, category chips and a card grid. Tapping a card opens the detail modal. */ +@Composable +fun CaramelModsScreen( + state: CaramelLauncherState, + onToggle: (CaramelModInfo, Boolean) -> Unit, + onOpenFolder: () -> Unit, +) { + var q by remember { mutableStateOf("") } + var chip by remember { mutableStateOf(0) } + CaramelPage( + "Mods", + "Browse, install and toggle mods for this instance", + { + SearchField(q, { q = it }, "Search mods", Modifier.width(300.dp)) + Spacer(Modifier.width(10.dp)) + GlassButton("Open mods folder", onOpenFolder, icon = Icons.Rounded.Folder) + }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("All", "Installed", "Performance", "Visual", "Utility", "Fun").forEachIndexed { i, t -> + FilterChip(t, chip == i, { chip = i }) + } + } + val list = remember(q, state.mods) { + state.mods.filter { q.trim().isEmpty() || it.name.contains(q.trim(), ignoreCase = true) } + } + if (list.isEmpty()) { + EmptyState("No mods installed", "Mods you add to this instance appear here.", Modifier.fillMaxSize()) + } else { + LazyVerticalGrid( + GridCells.Adaptive(270.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items(list, key = { it.name }) { m -> + GlassPanel( + Modifier.fillMaxWidth().height(218.dp) + .clickable(remember { MutableInteractionSource() }, null) { state.selectedMod = m }, + RoundedCornerShape(20.dp), + elevation = 8.dp, + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + IconTile(modIcon(m), accents[modIconIndex(m)], 48) + Column { + Text(m.name, style = T.Subtitle) + Text("by ${m.author}", style = T.Body) + } + } + Text(m.description, style = T.Body, maxLines = 3) + StatusLabel(if (m.enabled) "Enabled" else "Disabled", if (m.enabled) C.Green else C.Text2) + Spacer(Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("v${m.version}", style = T.Caption, modifier = Modifier.weight(1f)) + CaramelToggle(m.enabled, { onToggle(m, it) }) + } + } + } + } + } + } + } +} + +private fun modIcon(m: CaramelModInfo): ImageVector = listOf( + Icons.Rounded.Extension, Icons.Rounded.Bolt, Icons.Rounded.Place, Icons.Rounded.Dashboard, +)[modIconIndex(m)] + +private fun modIconIndex(m: CaramelModInfo): Int = (m.name.hashCode() and 0x7fffffff) % 4 + +/** The Servers screen: a featured banner over the saved-server rows. */ +@Composable +fun CaramelServersScreen(state: CaramelLauncherState, onJoin: (CaramelServerInfo) -> Unit) { + CaramelPage("Servers", "Featured and saved servers") { + val featured = state.servers.firstOrNull() + if (featured != null) { + GlassPanel(Modifier.fillMaxWidth().height(180.dp), RoundedCornerShape(24.dp)) { + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(listOf(C.Blue.copy(0.28f), C.Caramel.copy(0.16f))))) + Row(Modifier.fillMaxSize().padding(36.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(featured.name, style = T.Display.copy(fontSize = 26.sp_)) + Text(featured.address, style = T.Body.copy(fontSize = 13.sp_)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + featured.online?.let { StatusLabel("$it playing", C.Green) } + VersionRangeLabel(featured.minVersion, featured.maxVersion) + } + } + PrimaryButton("Join server", { onJoin(featured) }, Modifier.width(176.dp), icon = Icons.Rounded.PlayArrow) + } + } + } + if (state.servers.isEmpty()) { + EmptyState("No servers saved", "Add a server and it appears here.", Modifier.fillMaxWidth().weight(1f)) + } else { + Column(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { + state.servers.forEachIndexed { i, s -> + GlassPanel(Modifier.fillMaxWidth().height(80.dp), RoundedCornerShape(20.dp), elevation = 6.dp) { + Row( + Modifier.fillMaxSize().padding(horizontal = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + ServerIcon(accents[i % 4], 52) + Column(Modifier.weight(1f)) { + Text(s.name, style = T.Subtitle.copy(fontSize = 15.sp_)) + Text(s.address, style = T.Body) + } + s.online?.let { Text("$it online", style = T.Label) } + if (s.online != null) Icon(Icons.Rounded.Wifi, null, Modifier.size(18.dp), C.Green) + s.ping?.let { Text("$it ms", style = T.Body) } + VersionRangeLabel(s.minVersion, s.maxVersion) + GlassButton("Join", { onJoin(s) }, icon = Icons.Rounded.PlayArrow, height = 36) + } + } + } + } + } + } +} + +/** The shared empty state, so a screen never draws a fabricated row in place of real data. */ +@Composable +internal fun EmptyState(title: String, body: String, modifier: Modifier = Modifier) { + GlassPanel(modifier, RoundedCornerShape(20.dp), elevation = 6.dp) { + Column( + Modifier.fillMaxSize().padding(28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = T.Subtitle) + Spacer(Modifier.height(6.dp)) + Text(body, style = T.Body, textAlign = TextAlign.Center) + } + } +} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt new file mode 100644 index 00000000..fcb89a24 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt @@ -0,0 +1,156 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import org.chimeramc.client.ui.compose.caramel.C +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.CaramelLoader +import org.chimeramc.client.ui.compose.caramel.CaramelModInfo +import org.chimeramc.client.ui.compose.caramel.CaramelModal +import org.chimeramc.client.ui.compose.caramel.CaramelProfileDropdown +import org.chimeramc.client.ui.compose.caramel.CaramelVersionDropdown +import org.chimeramc.client.ui.compose.caramel.GlassButton +import org.chimeramc.client.ui.compose.caramel.GlassPanel +import org.chimeramc.client.ui.compose.caramel.LoaderTile +import org.chimeramc.client.ui.compose.caramel.PrimaryButton +import org.chimeramc.client.ui.compose.caramel.T +import org.chimeramc.client.ui.compose.caramel.Tone +import org.chimeramc.client.ui.compose.caramel.sp_ + +/** + * The pack's launch menu, its mod-detail modal and the Caramel Client app menu. + * + *

The pack anchors the launch menu to a fixed desktop offset. On a phone the surface is a + * different shape, so the menu is centred as a popup; its contents (version, loaders, profile + * mode, launch) are the pack's. + */ +@Composable +fun CaramelLaunchMenuOverlay(state: CaramelLauncherState, onLaunch: () -> Unit) { + if (!state.launchMenu) return + Popup( + alignment = Alignment.Center, + onDismissRequest = { state.launchMenu = false }, + properties = PopupProperties(focusable = true), + ) { + GlassPanel(Modifier.width(372.dp), RoundedCornerShape(22.dp), popup = true, elevation = 40.dp) { + Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Version", style = T.Title.copy(fontSize = 15.sp_), modifier = Modifier.weight(1f)) + CaramelVersionDropdown(state.versions, state.version, onSelect = { state.version = it }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Loaders", style = T.Title.copy(fontSize = 15.sp_), modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CaramelLoader.entries.forEach { LoaderTile(it, state.loader == it, { state.loader = it }) } + } + } + CaramelProfileDropdown(state.profile, { state.profile = it }, width = 328) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + GlassButton(null, { state.launchMenu = false; state.instanceSettings = true }, icon = Icons.Rounded.Settings, height = 44) + PrimaryButton("Launch Game", { state.launchMenu = false; onLaunch() }, Modifier.weight(1f), icon = Icons.Rounded.RocketLaunch) + } + } + } + } +} + +/** The mod detail modal, opened by tapping a Mods card. */ +@Composable +fun CaramelModDetailModal(state: CaramelLauncherState, onToggle: (CaramelModInfo, Boolean) -> Unit) { + val mod = state.selectedMod + CaramelModal(mod != null, { state.selectedMod = null }, Modifier.width(520.dp)) { + if (mod == null) return@CaramelModal + Column(Modifier.fillMaxSize().padding(28.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(mod.name, style = T.Display.copy(fontSize = 20.sp_, fontWeight = FontWeight.Medium)) + Text("by ${mod.author} · v${mod.version}", style = T.Body) + } + GlassButton(null, { state.selectedMod = null }, icon = Icons.Rounded.Close, height = 32) + } + Text(mod.description, style = T.Body.copy(fontSize = 13.sp_)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(if (mod.enabled) "Enabled" else "Disabled", style = T.Label, modifier = Modifier.weight(1f)) + CaramelToggleRef(mod.enabled) { onToggle(mod, it) } + } + Spacer(Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassButton("Open config", {}, Modifier.weight(1f), height = 40) + PrimaryButton(if (mod.enabled) "Disable" else "Enable", { onToggle(mod, !mod.enabled) }, Modifier.weight(1f), height = 40) + } + } + } +} + +/** The Caramel Client app menu: the dropdown by the title bar's arrow, which carries Quit. */ +@Composable +fun CaramelAppMenuOverlay(state: CaramelLauncherState, onQuit: () -> Unit) { + if (!state.appMenu) return + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(70, 40), + onDismissRequest = { state.appMenu = false }, + properties = PopupProperties(focusable = true), + ) { + GlassPanel(Modifier.width(230.dp), RoundedCornerShape(18.dp), popup = true, elevation = 36.dp) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + CaramelAppMenuRow("Settings", false) { state.appMenu = false; state.launcherSettings = true } + CaramelAppMenuRow("Instance settings", false) { state.appMenu = false; state.instanceSettings = true } + CaramelAppMenuRow("About", false) { state.appMenu = false } + Spacer(Modifier.height(4.dp)) + CaramelAppMenuRow("Quit", true) { state.appMenu = false; onQuit() } + } + } + } +} + +@Composable +private fun CaramelAppMenuRow(label: String, danger: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(10.dp)) + .clickable(remember { MutableInteractionSource() }, null, onClick = onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = T.Label.copy(color = if (danger) C.Red else C.Text, fontWeight = FontWeight.Medium), + ) + } +} + +/** A local alias so this file does not import the control just for one call. */ +@Composable +private fun CaramelToggleRef(checked: Boolean, onChange: (Boolean) -> Unit) = + org.chimeramc.client.ui.compose.caramel.CaramelToggle(checked, onChange) diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt new file mode 100644 index 00000000..547b9c6a --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt @@ -0,0 +1,174 @@ +package org.chimeramc.client.ui.compose.caramel.launcher + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Article +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.ShoppingCart +import androidx.compose.material.icons.rounded.SportsEsports +import androidx.compose.material.icons.rounded.ViewInAr +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.chimeramc.client.R +import org.chimeramc.client.ui.compose.caramel.C +import org.chimeramc.client.ui.compose.caramel.CaramelBackdrop +import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState +import org.chimeramc.client.ui.compose.caramel.CaramelPill +import org.chimeramc.client.ui.compose.caramel.CaramelTab +import org.chimeramc.client.ui.compose.caramel.GlassPanel +import org.chimeramc.client.ui.compose.caramel.ServerIcon +import org.chimeramc.client.ui.compose.caramel.T +import org.chimeramc.client.ui.compose.caramel.sp_ + +/** + * The launcher frame, ported from the pack's `launcher/Shell.kt`. + * + *

The pack mounts its frame inside a desktop `Window` with a `WindowDraggableArea`. On Android + * the window is the activity, so the drag area is dropped and the title bar is a plain row. The + * backdrop, the glass title bar and the 56dp sidebar rail are the pack's. + */ +@Composable +fun CaramelLauncherFrame( + state: CaramelLauncherState, + content: @Composable (Modifier) -> Unit, +) { + Box(Modifier.fillMaxSize().background(C.Abyss)) { + CaramelBackdrop() + Column(Modifier.fillMaxSize()) { + CaramelTitleBar(state) + Row( + Modifier.fillMaxSize().padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CaramelSidebar(state) + Box(Modifier.weight(1f).fillMaxHeight()) { content(Modifier.fillMaxSize()) } + } + } + } +} + +@Composable +private fun CaramelTitleBar(state: CaramelLauncherState) { + Row( + Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Image(painterResource(R.drawable.ic_caramel_logo), null, Modifier.size(30.dp)) + Text("Caramel Client", style = T.Subtitle) + val menuShape = RoundedCornerShape(8.dp) + Box( + Modifier.size(22.dp).clip(menuShape) + .background(Color.White.copy(if (state.appMenu) 0.14f else 0.06f)) + .border(1.dp, Color.White.copy(0.18f), menuShape) + .clickable(remember { MutableInteractionSource() }, null) { state.appMenu = !state.appMenu }, + Alignment.Center, + ) { + Icon( + if (state.appMenu) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, + null, Modifier.size(15.dp), C.Text2, + ) + } + CaramelPill { + Icon(Icons.Rounded.RocketLaunch, null, Modifier.size(13.dp), C.Text2) + Text("Launchpad", style = T.Label.copy(fontSize = 11.5.sp_, color = C.Text2)) + } + Spacer(Modifier.width(12.dp)) + Text("Quick play", style = T.Caption) + // Quick play tiles: one per saved server, capped so the row never overflows. + state.servers.take(8).forEachIndexed { i, _ -> ServerIcon(quickPlayAccent(i), 26) } + Spacer(Modifier.weight(1f)) + Text("${state.runningInstances} instances", style = T.Caption) + CaramelPill(onClick = { state.accountMenu = !state.accountMenu }) { + if (state.signedIn) { + Icon(Icons.Rounded.AccountCircle, null, Modifier.size(18.dp), C.Text2) + Text(state.accountName, style = T.Label.copy(fontSize = 11.5.sp_)) + } else { + Text("Sign in", style = T.Label.copy(fontSize = 11.5.sp_)) + } + Icon(Icons.Rounded.KeyboardArrowDown, null, Modifier.size(14.dp), C.Text3) + } + } +} + +/** The eight quick-play accents, in order, so a tile keeps its colour as servers are added. */ +private fun quickPlayAccent(i: Int): Color = listOf( + C.Caramel, C.Blue, C.Green, C.Red, + Color(0xFF966EFF), Color(0xFF4AC8E6), Color(0xFFFF8C3C), Color(0xFF7882FF), +)[i % 8] + +private val nav = listOf( + CaramelTab.Home to Icons.Rounded.SportsEsports, + CaramelTab.Mods to Icons.Rounded.Extension, + CaramelTab.Servers to Icons.Rounded.Public, + CaramelTab.Cosmetics to Icons.Rounded.ViewInAr, + CaramelTab.News to Icons.Rounded.Article, + CaramelTab.Store to Icons.Rounded.ShoppingCart, +) + +@Composable +private fun CaramelSidebar(state: CaramelLauncherState) { + GlassPanel(Modifier.width(56.dp).fillMaxHeight(), RoundedCornerShape(18.dp), elevation = 12.dp) { + Column( + Modifier.fillMaxSize().padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + nav.forEach { (tab, icon) -> CaramelNavItem(icon, state.tab == tab) { state.tab = tab } } + Spacer(Modifier.weight(1f)) + CaramelNavItem(Icons.Rounded.Settings, state.launcherSettings) { state.launcherSettings = true } + } + } +} + +@Composable +private fun CaramelNavItem(icon: ImageVector, active: Boolean, onClick: () -> Unit) { + Box(Modifier.size(44.dp), Alignment.Center) { + Box( + Modifier.size(44.dp).clip(RoundedCornerShape(13.dp)) + .background(if (active) Color.White.copy(0.12f) else Color.Transparent) + .then(if (active) Modifier.border(1.dp, Color.White.copy(0.2f), RoundedCornerShape(13.dp)) else Modifier) + .clickable(remember { MutableInteractionSource() }, null, onClick = onClick), + Alignment.Center, + ) { Icon(icon, null, Modifier.size(22.dp), if (active) C.Text else C.Text3) } + if (active) { + Box( + Modifier.align(Alignment.CenterStart).offset((-14).dp).size(3.dp, 22.dp) + .background(C.Caramel, RoundedCornerShape(2.dp)), + ) + } + } +} diff --git a/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java b/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java new file mode 100644 index 00000000..c04c774e --- /dev/null +++ b/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java @@ -0,0 +1,85 @@ +package org.chimeramc.client.launcher.core.mods.inbuilt.overlay; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Every reason a module can be unavailable has its own message, and the surface names it. + * + *

A single "needs game data" line for every cause told the user the wrong thing: a module waiting + * for an engine feed, a module a server decides, a module waiting for a backend capability and a + * module waiting for a present hook are four different situations with four different answers. This + * pins that the four messages exist, that the launcher-side catalogue builder words each reason id + * with the matching resource, and that the native menu renders the wording it is handed rather than + * a raw id. + * + *

A build machine has no {@code R} class, so this reads the real {@code strings.xml} and source + * rather than a resource id, the same way the layout guards do. + */ +public class ModUnavailableReasonResourcesTest { + + private static final String[] REASON_STRINGS = { + "mod_unavailable_reason", + "mod_unavailable_reason_server", + "mod_unavailable_reason_backend", + "mod_unavailable_reason_hook", + }; + + /** The one Java surface that words an unavailable reason for the native menu. */ + private static final String SURFACE = + "app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/" + + "InGameMenuController.java"; + + @Test + public void everyReasonHasItsOwnMessage() throws IOException { + String strings = read("app/src/main/res/values/strings.xml"); + for (String name : REASON_STRINGS) { + assertTrue("values/strings.xml must define " + name, + strings.contains("name=\"" + name + "\"")); + } + } + + @Test + public void theCatalogueBuilderNamesEveryReason() throws IOException { + String source = read(SURFACE); + for (String name : REASON_STRINGS) { + assertTrue(SURFACE + " must reference R.string." + name + + " so the reason is shown with the right wording", + source.contains("R.string." + name)); + } + } + + @Test + public void theNativeMenuRendersTheWordingItIsHandedNotARawId() throws IOException { + // The reason crosses as the already-worded message; the native side draws it as a status + // label, so a raw id can never reach the screen. + String render = read("app/src/main/cpp/sweetloader/src/pl/menu/MenuRender.cpp"); + assertTrue("the native menu must draw the module's unavailable wording", + render.contains("m.unavailableReason")); + assertFalse("the menu must not render a raw reason id", + render.contains("\"no_game_data\"") || render.contains("\"server_decided\"") + || render.contains("\"no_backend\"") || render.contains("\"no_hook\"")); + } + + private static Path resolve(String relativePath) { + Path dir = Paths.get("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve(relativePath); + if (Files.exists(candidate)) return candidate; + dir = dir.getParent(); + } + throw new IllegalStateException("could not find " + relativePath); + } + + private static String read(String relativePath) throws IOException { + return new String(Files.readAllBytes(resolve(relativePath)), StandardCharsets.UTF_8); + } +}