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 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 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 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 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 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 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 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 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 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 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);
+ }
+}
>(emptyList())
+ var version 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