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); + } +}