From f488a894a343a22e32952e59de9629e71ce2fad8 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 11:26:56 +0200 Subject: [PATCH 01/22] feat: add Minecraft capes and cape override handling to the wardrobe --- .../cosmetics/MixinAbstractClientPlayer.java | 69 ++-- .../client/PolyPlusCosmeticsConfig.kt | 7 + .../polyplus/client/cosmetics/CapeResolver.kt | 111 ++++++ .../client/cosmetics/MojangCapeService.kt | 217 ++++++++++ .../client/gui/PolyPlusCosmeticsScreen.kt | 370 +++++++++++++++--- .../client/gui/PolyPlusMainMenuScreen.kt | 2 +- .../gui/preview/PlayerPreviewRenderer.kt | 20 +- .../client/gui/preview/PlayerPreviewSource.kt | 2 + 8 files changed, 700 insertions(+), 98 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt diff --git a/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java b/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java index e1c2b9f2..95b617b0 100644 --- a/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java +++ b/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java @@ -2,9 +2,10 @@ //? if > 1.8.9 { import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.authlib.GameProfile; import net.minecraft.client.multiplayer.PlayerInfo; import net.minecraft.client.player.AbstractClientPlayer; -import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache; +import org.polyfrost.polyplus.client.cosmetics.CapeResolver; import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment; import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess; import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess; @@ -17,7 +18,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; //? if >= 1.21.10 { -import net.minecraft.core.ClientAsset; import net.minecraft.world.entity.player.PlayerSkin; //?} @@ -25,7 +25,7 @@ /*import net.minecraft.client.resources.PlayerSkin; *///?} -@Mixin(AbstractClientPlayer.class) +@Mixin(value = AbstractClientPlayer.class, priority = 1500) public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, PlayerCosmeticsAccess { @Shadow private PlayerInfo playerInfo; @@ -67,50 +67,37 @@ public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, P return original; } + GameProfile profile = this.playerInfo.getProfile(); //~ if >= 1.21.10 'getId' -> 'id' - var capeLocation = CosmeticAssetCache.getCapeTexture(this.playerInfo.getProfile().id()); - if (capeLocation == null) { - return original; - } - - return new PlayerSkin( - // FIXME: how to simplify this - //? if >= 1.21.10 { - original.body(), - new ClientAsset.ResourceTexture(capeLocation), - original.elytra(), - original.model(), - original.secure() - //?} else { - /*original.texture(), - original.textureUrl(), - capeLocation, - original.elytraTexture(), - original.model(), - original.secure() - *///?} - ); + return CapeResolver.applyCape(profile.id(), profile, original); } } //?} else { -/*import com.mojang.authlib.GameProfile; +/*import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.authlib.GameProfile; import net.minecraft.client.entity.living.player.ClientPlayerEntity; +import net.minecraft.client.network.PlayerInfo; import net.minecraft.entity.living.player.PlayerEntity; import net.minecraft.resources.Identifier; import net.minecraft.world.World; import org.polyfrost.polyplus.client.PolyPlusConfig; +import org.polyfrost.polyplus.client.cosmetics.CapeResolver; import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache; import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment; +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService; import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess; import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess; import org.polyfrost.polyplus.client.emotes.playback.EmoteController; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -@Mixin(ClientPlayerEntity.class) +import java.util.UUID; + +@Mixin(value = ClientPlayerEntity.class, priority = 1500) public abstract class MixinAbstractClientPlayer extends PlayerEntity implements PlayerEmotesAccess, PlayerCosmeticsAccess { @Unique private EmoteController polyplus$emoteController; @@ -122,6 +109,9 @@ private MixinAbstractClientPlayer(World world, GameProfile profile) { super(world, profile); } + @Shadow + protected abstract PlayerInfo getInfo(); + @Override public EmoteController polyplus$emoteController() { EmoteController controller = polyplus$emoteController; @@ -148,10 +138,27 @@ public void tick() { polyplus$emoteController().tick((ClientPlayerEntity) (Object) this); } - @Inject(method = "getCapeTextureLocation", at = @At("HEAD"), cancellable = true) - private void polyplus$replaceCapeTexture(CallbackInfoReturnable cir) { - Identifier cape = CosmeticAssetCache.getCapeTexture(((ClientPlayerEntity) (Object) this).getUuid()); - if (cape != null) cir.setReturnValue(cape); + @ModifyReturnValue(method = "getCapeTextureLocation", at = @At("RETURN")) + private Identifier polyplus$replaceCapeTexture(Identifier original) { + UUID playerId = ((ClientPlayerEntity) (Object) this).getUuid(); + switch (CapeResolver.decide(playerId, original == null ? null : original.getNamespace())) { + case POLYPLUS: + return polyplus$orOriginal(CosmeticAssetCache.getCapeTexture(playerId), original); + case MOJANG: + return polyplus$orOriginal(MojangCapeService.localCapeTexture(), original); + case VANILLA: + PlayerInfo info = getInfo(); + return info == null ? null : info.getCapeTexture(); + case NONE: + return null; + default: + return original; + } + } + + @Unique + private static Identifier polyplus$orOriginal(Identifier cape, Identifier original) { + return cape != null ? cape : original; } @Inject(method = "getFovModifier", at = @At("HEAD"), cancellable = true) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt index e8647995..7f5af864 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt @@ -36,6 +36,13 @@ object PolyPlusCosmeticsConfig : Config( ) var hideFeetCosmeticsWithBoots = true + @JvmStatic + @Switch( + title = "Force PolyPlus Capes", + description = "Show the capes picked in the wardrobe even when another mod sets its own cape.", + ) + var forcePolyPlusCapes = false + @JvmStatic @Dropdown( title = "Capes", diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt new file mode 100644 index 00000000..67ad3262 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt @@ -0,0 +1,111 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.fabricmc.loader.api.FabricLoader +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig +import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.util.UUID + +//? if > 1.8.9 { +import com.mojang.authlib.GameProfile +import net.minecraft.client.Minecraft +import net.minecraft.resources.Identifier +//?} +//? if >= 1.21.10 { +import net.minecraft.core.ClientAsset +import net.minecraft.world.entity.player.PlayerSkin +//?} elif > 1.8.9 { +/*import net.minecraft.client.resources.PlayerSkin +*///?} + +enum class CapeDecision { + KEEP, + POLYPLUS, + MOJANG, + VANILLA, + NONE, +} + +object CapeResolver { + private const val VANILLA_NAMESPACE = "minecraft" + + var localOverrideModId by mutableStateOf(null) + private set + + val localOverrideModName: String? + get() = localOverrideModId?.let { id -> + FabricLoader.getInstance().getModContainer(id).map { it.metadata.name }.orElse(id) + } + + @JvmStatic + fun decide(playerId: UUID, currentNamespace: String?): CapeDecision { + val foreign = currentNamespace != null && + currentNamespace != VANILLA_NAMESPACE && + currentNamespace != PolyPlusConstants.ID + val local = isLocal(playerId) + if (local) { + val modId = if (foreign) currentNamespace else null + if (localOverrideModId != modId) localOverrideModId = modId + } + + if (foreign && !PolyPlusCosmeticsConfig.forcePolyPlusCapes) return CapeDecision.KEEP + if (CosmeticAssetCache.getCapeTexture(playerId) != null) return CapeDecision.POLYPLUS + + val mojangKnown = MojangCapeService.overridesLocalCape(playerId) || + (foreign && local && MojangCapeService.state is MojangCapeState.Loaded) + if (mojangKnown) { + if (MojangCapeService.localCapeHidden()) return CapeDecision.NONE + if (MojangCapeService.localCapeTexture() != null) return CapeDecision.MOJANG + } + return if (foreign) CapeDecision.VANILLA else CapeDecision.KEEP + } + + //? if > 1.8.9 { + @JvmStatic + fun applyCape(playerId: UUID, profile: GameProfile, original: PlayerSkin): PlayerSkin = + when (decide(playerId, capeNamespace(original))) { + CapeDecision.POLYPLUS -> withCape(original, CosmeticAssetCache.getCapeTexture(playerId)) + CapeDecision.MOJANG -> withCape(original, MojangCapeService.localCapeTexture()) + CapeDecision.VANILLA -> withCapeFrom(original, vanillaSkin(profile)) + CapeDecision.NONE -> withCapeFrom(original, null) + CapeDecision.KEEP -> original + } + + private fun vanillaSkin(profile: GameProfile): PlayerSkin? = runCatching { + //? if >= 1.21.10 { + Minecraft.getInstance().skinManager.createLookup(profile, false).get() + //?} else { + /*Minecraft.getInstance().skinManager.getInsecureSkin(profile) + *///?} + }.getOrNull() + + private fun capeNamespace(skin: PlayerSkin): String? = + //? if >= 1.21.10 { + skin.cape()?.texturePath()?.namespace + //?} else { + /*skin.capeTexture()?.namespace + *///?} + + private fun withCape(original: PlayerSkin, cape: Identifier?): PlayerSkin { + if (cape == null) return original + //? if >= 1.21.10 { + return PlayerSkin(original.body(), ClientAsset.ResourceTexture(cape), original.elytra(), original.model(), original.secure()) + //?} else { + /*return PlayerSkin(original.texture(), original.textureUrl(), cape, original.elytraTexture(), original.model(), original.secure()) + *///?} + } + + private fun withCapeFrom(original: PlayerSkin, source: PlayerSkin?): PlayerSkin = + //? if >= 1.21.10 { + PlayerSkin(original.body(), source?.cape(), original.elytra(), original.model(), original.secure()) + //?} else { + /*PlayerSkin(original.texture(), original.textureUrl(), source?.capeTexture(), original.elytraTexture(), original.model(), original.secure()) + *///?} + //?} + + private fun isLocal(playerId: UUID): Boolean = + runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() == playerId +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt new file mode 100644 index 00000000..581aed44 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt @@ -0,0 +1,217 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import net.minecraft.client.Minecraft +import net.minecraft.resources.Identifier +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.assets.RemoteTextures +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class MojangCape(val id: String, val name: String, val url: String, val active: Boolean) { + val textureHash: String get() = url.substringAfterLast('/') +} + +sealed interface MojangCapeState { + data object Idle : MojangCapeState + data object Loading : MojangCapeState + data class Loaded(val capes: List) : MojangCapeState + data class Unavailable(val reason: String) : MojangCapeState +} + +class MojangCapeException(message: String) : Exception(message) + +object MojangCapeService { + private val LOGGER = LogManager.getLogger("polyplus/mojang-capes") + + private const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" + private const val ACTIVE_CAPE_URL = "$PROFILE_URL/capes/active" + private const val SIGNED_OUT_MESSAGE = "Sign in with a Microsoft account to choose a Minecraft cape." + + private val requestLock = Mutex() + private val textures = ConcurrentHashMap() + private val downloading = ConcurrentHashMap.newKeySet() + private val cacheDir = CosmeticAssetCache.baseDir.toPath().resolve("mojang") + + var state by mutableStateOf(MojangCapeState.Idle) + private set + + var busy by mutableStateOf(false) + private set + + var textureVersion by mutableIntStateOf(0) + private set + + @Volatile + private var changedThisSession = false + + suspend fun refreshIfNeeded() { + if (state is MojangCapeState.Idle) refresh() + } + + suspend fun refresh(): Result> = requestLock.withLock { + if (state !is MojangCapeState.Loaded) state = MojangCapeState.Loading + request { token -> PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } } + } + + suspend fun select(capeId: String): Result> = mutate { token -> + PolyPlusClient.HTTP.put(ACTIVE_CAPE_URL) { + authorize(token) + contentType(ContentType.Application.Json) + setBody(SelectCapeRequest(capeId)) + } + } + + suspend fun clear(): Result> = mutate { token -> + PolyPlusClient.HTTP.delete(ACTIVE_CAPE_URL) { authorize(token) } + } + + fun activeCape(): MojangCape? = (state as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.active } + + fun texture(cape: MojangCape): Identifier? { + val hash = cape.textureHash + val location = textures[hash] + if (location != null && RemoteTextures.isRegistered(texturePath(hash))) return location + load(cape) + return null + } + + @JvmStatic + fun localCapeTexture(): Identifier? = activeCape()?.let(::texture) + + @JvmStatic + fun localCapeHidden(): Boolean = state is MojangCapeState.Loaded && activeCape() == null + + @JvmStatic + fun overridesLocalCape(uuid: UUID): Boolean = + changedThisSession && state is MojangCapeState.Loaded && uuid == ClientPlatform.localPlayerUuid() + + fun failureMessage(error: Throwable): String = when { + error is MojangCapeException -> error.message ?: SIGNED_OUT_MESSAGE + error is ClientRequestException && error.response.status == HttpStatusCode.TooManyRequests -> + "Minecraft is limiting cape changes right now. Try again in a minute." + else -> "Couldn't reach Minecraft's servers." + } + + private suspend fun mutate(call: suspend (String) -> HttpResponse): Result> = + requestLock.withLock { + busy = true + try { + request(call).onSuccess { changedThisSession = true } + } finally { + busy = false + } + } + + private suspend fun request(call: suspend (String) -> HttpResponse): Result> { + val result = runSuspendCatching { + val token = accessToken() ?: throw MojangCapeException(SIGNED_OUT_MESSAGE) + val response = call(token) + if (response.status == HttpStatusCode.Unauthorized) throw MojangCapeException(SIGNED_OUT_MESSAGE) + response.body().capes.map { it.toCape() } + } + result.onSuccess { capes -> + state = MojangCapeState.Loaded(capes) + capes.forEach(::texture) + }.onFailure { error -> + LOGGER.warn("Minecraft cape request failed", error) + if (state !is MojangCapeState.Loaded) state = MojangCapeState.Unavailable(failureMessage(error)) + } + return result + } + + private fun HttpRequestBuilder.authorize(token: String) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + } + + private fun accessToken(): String? = runCatching { + //? if > 1.8.9 { + Minecraft.getInstance().user.accessToken + //?} else { + /*Minecraft.getInstance().session.accessToken + *///?} + }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } + + private fun load(cape: MojangCape) { + val hash = cape.textureHash + if (hash.isBlank() || !downloading.add(hash)) return + PolyPlusClient.SCOPE.launch { + runSuspendCatching { + val file = cacheDir.resolve("$hash.png") + if (!Files.isRegularFile(file)) { + val bytes = PolyPlusClient.HTTP.get(cape.url).bodyAsBytes() + withContext(Dispatchers.IO) { + Files.createDirectories(cacheDir) + Files.write(file, bytes) + } + } + RemoteTextures.register(texturePath(hash), file) + textures[hash] = location(hash) + textureVersion++ + }.onFailure { LOGGER.warn("Failed to load the texture for Minecraft cape {}", cape.name, it) } + downloading.remove(hash) + } + } + + private fun location(hash: String): Identifier = + Identifier.fromNamespaceAndPath(PolyPlusConstants.ID, "mojang_cape/$hash") + + private fun texturePath(hash: String): Identifier { + //? if >= 1.21.10 { + return Identifier.fromNamespaceAndPath(PolyPlusConstants.ID, "textures/mojang_cape/$hash.png") + //?} else { + /*return location(hash) + *///?} + } + + @Serializable + private data class ProfileResponse(val capes: List = emptyList()) + + @Serializable + private data class CapeResponse( + val id: String, + val state: String = "", + val url: String = "", + val alias: String = "", + ) { + fun toCape(): MojangCape = MojangCape( + id = id, + name = alias.ifBlank { "Minecraft cape" }, + url = url, + active = state.equals("ACTIVE", ignoreCase = true), + ) + } + + @Serializable + private data class SelectCapeRequest(val capeId: String) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt index e3daa6b9..934e0894 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -52,6 +53,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -106,7 +108,9 @@ import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig import org.polyfrost.polyplus.client.cosmetics.BillingService +import org.polyfrost.polyplus.client.cosmetics.CapeResolver import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment @@ -114,6 +118,9 @@ import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView import org.polyfrost.polyplus.client.cosmetics.CosmeticLoadProgress import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.cosmetics.CosmeticStore +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState import org.polyfrost.polyplus.client.gui.preview.PlayerPreview import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource import org.polyfrost.polyplus.client.network.http.responses.BodySlot @@ -145,8 +152,6 @@ object PolyPlusOneConfigIntegration { @JvmStatic fun navigationGroups(original: List): List { - if (!PrivacyConsent.allowsOnlineServices()) return original - if (original.any { group -> group.routes.any { it.route == PolyPlusCosmeticsRoute } }) { return original } @@ -164,7 +169,6 @@ object PolyPlusOneConfigIntegration { @JvmStatic fun addRoutes(builder: NavGraphBuilder) { - if (!PrivacyConsent.allowsOnlineServices()) return builder.polyPlusCosmeticsGraph() } @@ -273,7 +277,7 @@ private data class CosmeticUiItem( @Composable private fun PolyPlusCosmeticsScreen() { if (!PrivacyConsent.allowsOnlineServices()) { - OnlineFeaturesDisabled() + MinecraftCapesOnlyScreen() return } var loadState by remember { mutableStateOf(CosmeticLoadProgress.snapshot()) } @@ -373,6 +377,7 @@ private fun PolyPlusCosmeticsScreen() { }, onRefresh = { PolyPlusClient.refreshCosmetics() + PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } refreshKey++ manualRefreshKey++ status = "Refreshing cosmetic data..." @@ -433,6 +438,13 @@ private fun PolyPlusCosmeticsScreen() { } } }, + onSelectMojangCape = { cape -> toggleMojangCape(cape) { status = it } }, + onOpenStore = { + tab = PolyPlusTab.Store + tabResolved = true + showCart = false + status = null + }, ) PolyPlusTab.Store -> StoreScreen( @@ -574,19 +586,23 @@ private fun WardrobeScreen( onSelect: (CosmeticUiItem) -> Unit, onSelectVariant: (Int, Int) -> Unit, onEquip: (CosmeticUiItem) -> Unit, + onSelectMojangCape: (MojangCape) -> Unit, + onOpenStore: () -> Unit, + polyPlusAvailable: Boolean = true, ) { var selectedType by remember { mutableStateOf(CosmeticType.Cape) } var stockedTypes by remember { mutableStateOf(CosmeticType.entries.filter { it != CosmeticType.Unknown }) } LaunchedEffect(Unit) { - stockedTypes = CosmeticStore.stockedTypes() + if (polyPlusAvailable) stockedTypes = CosmeticStore.stockedTypes() } - val railTypes = remember(stockedTypes, items) { + val railTypes = remember(stockedTypes, items, polyPlusAvailable) { val owned = items.mapTo(mutableSetOf()) { it.type } CosmeticType.entries.filter { - it != CosmeticType.Unknown && (it in stockedTypes || it in owned) + it != CosmeticType.Unknown && + (it == CosmeticType.Cape || (polyPlusAvailable && (it in stockedTypes || it in owned))) } } @@ -606,6 +622,15 @@ private fun WardrobeScreen( } } + LaunchedEffect(Unit) { + MojangCapeService.refreshIfNeeded() + } + var selectedMojangCapeId by remember { mutableStateOf(null) } + val mojangState = MojangCapeService.state + val selectedMojangCape = (mojangState as? MojangCapeState.Loaded)?.capes + ?.firstOrNull { it.id == selectedMojangCapeId } + ?.takeIf { selectedType == CosmeticType.Cape } + val previewItem = selected?.takeIf { it.type == selectedType } ?: displayItems.firstOrNull() Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(19.dp)) { CategoryRail( @@ -613,19 +638,41 @@ private fun WardrobeScreen( onSelect = { selectedType = it }, types = railTypes, ) - CosmeticGrid( - items = displayItems, - modifier = Modifier.width(596.dp).fillMaxHeight(), - onSelect = onSelect, - onEquip = onEquip, - ) + if (selectedType == CosmeticType.Cape) { + CapeGrid( + polyPlusCapes = displayItems, + mojangState = mojangState, + mojangBusy = MojangCapeService.busy, + modifier = Modifier.width(596.dp).fillMaxHeight(), + onSelectMojangCape = { cape -> + selectedMojangCapeId = cape.id + onSelectMojangCape(cape) + }, + onSelect = { + selectedMojangCapeId = null + onSelect(it) + }, + onEquip = onEquip, + onOpenStore = onOpenStore, + polyPlusAvailable = polyPlusAvailable, + ) + } else { + CosmeticGrid( + items = displayItems, + modifier = Modifier.width(596.dp).fillMaxHeight(), + onSelect = onSelect, + onEquip = onEquip, + ) + } PreviewPanel( - selected = previewItem, + selected = previewItem?.takeIf { selectedMojangCape == null }, + heading = selectedMojangCape?.let { it.name to MOJANG_CAPE_COLLECTION }, status = status, selectedVariantId = previewItem?.let { selectedVariantId(it, variantPicks) }, onSelectVariant = { variantId -> previewItem?.let { onSelectVariant(it.groupId, variantId) } }, + showBack = selectedType == CosmeticType.Cape, showAuraColor = selectedType == CosmeticType.Aura, auraColor = auraColor, onPreviewAuraColor = onPreviewAuraColor, @@ -635,6 +682,172 @@ private fun WardrobeScreen( } } +private const val MOJANG_CAPE_COLLECTION = "Minecraft cape" + +@Composable +private fun CapeGrid( + polyPlusCapes: List, + mojangState: MojangCapeState, + mojangBusy: Boolean, + modifier: Modifier, + onSelectMojangCape: (MojangCape) -> Unit, + onSelect: (CosmeticUiItem) -> Unit, + onEquip: (CosmeticUiItem) -> Unit, + onOpenStore: () -> Unit, + polyPlusAvailable: Boolean, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(19.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item(key = "force-capes", span = { GridItemSpan(maxLineSpan) }) { + ForceCapesControl() + } + item(key = "mojang-header", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionHeader( + title = "Minecraft capes", + note = "Everyone sees this cape, including players without PolyPlus.", + ) + } + when (mojangState) { + MojangCapeState.Idle, MojangCapeState.Loading -> + item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionNote("Loading your Minecraft capes...") + } + is MojangCapeState.Unavailable -> + item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionNote(mojangState.reason) + } + is MojangCapeState.Loaded -> + if (mojangState.capes.isEmpty()) { + item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionNote("You don't own any Minecraft capes.") + } + } else { + items(mojangState.capes, key = { "mojang-${it.id}" }) { cape -> + MojangCapeCard(cape, enabled = !mojangBusy, onClick = { onSelectMojangCape(cape) }) + } + } + } + item(key = "polyplus-header", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionHeader( + title = "PolyPlus capes", + note = "Players with PolyPlus see this cape instead of your Minecraft cape.", + action = "Get more".takeIf { polyPlusAvailable }, + onAction = onOpenStore, + ) + } + if (!polyPlusAvailable) { + item(key = "polyplus-note", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionNote( + "PolyPlus capes need the Terms of Service and Privacy Policy. " + + "Accept them under Privacy in the PolyPlus settings.", + ) + } + } else if (polyPlusCapes.isEmpty()) { + item(key = "polyplus-note", span = { GridItemSpan(maxLineSpan) }) { + CapeSectionNote("You don't own any PolyPlus capes yet.") + } + } else { + items(polyPlusCapes, key = { "polyplus-${it.groupId}" }) { item -> + CosmeticCard( + item = item, + onSelect = { onSelect(item) }, + onEquip = { onEquip(item) }, + ) + } + } + } +} + +@Composable +private fun ForceCapesControl() { + var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } + val modName = CapeResolver.localOverrideModName + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SocialToggle("Force PolyPlus capes", checked = forced) { + forced = !forced + PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced + PolyPlusCosmeticsConfig.save() + } + val note = when { + modName == null -> "When another mod sets its own cape, that cape is shown unless this is on." + forced -> "$modName sets its own cape, but the capes picked here are shown instead." + else -> "Your cape is set by $modName. Turn this on to show the capes picked here." + } + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } +} + +@Composable +private fun CapeSectionHeader( + title: String, + note: String, + action: String? = null, + onAction: () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + if (action != null) { + Box( + modifier = Modifier.height(28.dp) + .clip(ppShape(7.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) + .clickable(onClick = onAction) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + SocialText(action, color = LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +private fun CapeSectionNote(text: String) { + Box( + modifier = Modifier.fillMaxWidth().height(57.dp) + .clip(ppShape(12.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } +} + +@Composable +private fun MojangCapeCard(cape: MojangCape, enabled: Boolean, onClick: () -> Unit) { + WardrobeCard( + name = cape.name, + collection = MOJANG_CAPE_COLLECTION, + equipped = cape.active, + enabled = enabled, + onClick = onClick, + ) { modifier -> + val textureVersion = MojangCapeService.textureVersion + val texture = remember(cape.textureHash, textureVersion) { MojangCapeService.texture(cape) } + val source = remember(texture) { + texture?.let { PlayerPreviewSource.Override(CosmeticEquipment(), mojangCape = it) } + } + CosmeticThumbnail( + source = source, + type = CosmeticType.Cape, + previewKey = "mojang-${cape.textureHash}-${texture != null}", + modifier = modifier, + ) + } +} + @Composable private fun HistoryScreen(ownedIds: Set, reloadKey: Int) { var transactions by remember { mutableStateOf>(emptyList()) } @@ -757,35 +970,54 @@ private fun CosmeticCard( onSelect: () -> Unit, onEquip: () -> Unit, ) { - val border = if (item.equipped) Accent else LocalTheme.current.borderColor - val buttonColor = if (item.equipped) Accent else Color(0xB3232D32) - val activate = { onSelect(); onEquip() } + WardrobeCard( + name = item.name, + collection = item.collection, + equipped = item.equipped, + onClick = { onSelect(); onEquip() }, + ) { modifier -> + val (source, loadTick) = rememberCosmeticPreviewSource(item) + CosmeticThumbnail( + source = source, + type = item.type, + previewKey = "card-${item.groupId}-$loadTick", + modifier = modifier, + ) + } +} + +@Composable +private fun WardrobeCard( + name: String, + collection: String, + equipped: Boolean, + onClick: () -> Unit, + enabled: Boolean = true, + thumbnail: @Composable (Modifier) -> Unit, +) { + val border = if (equipped) Accent else LocalTheme.current.borderColor + val buttonColor = if (equipped) Accent else Color(0xB3232D32) Box( modifier = Modifier.size(180.dp, 258.dp) + .alpha(if (enabled) 1f else 0.6f) .clip(ppShape(12.dp)) .background(cardBrush()) .border(1.dp, border, ppShape(12.dp)) - .clickable(onClick = activate), + .clickable(enabled = enabled, onClick = onClick), ) { - val (source, loadTick) = rememberCosmeticPreviewSource(item) - CosmeticThumbnail( - source = source, - type = item.type, - previewKey = "card-${item.groupId}-$loadTick", - modifier = Modifier.offset(17.dp, 17.dp).size(144.dp), - ) - CardLabel(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) - CardLabel(item.collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.offset(17.dp, 192.dp).width(146.dp)) + thumbnail(Modifier.offset(17.dp, 17.dp).size(144.dp)) + CardLabel(name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) + CardLabel(collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.offset(17.dp, 192.dp).width(146.dp)) Row( modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(36.dp) .background(buttonColor) - .clickable(onClick = activate), + .clickable(enabled = enabled, onClick = onClick), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - SocialText(if (item.equipped) "Equipped" else "Equip", color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + SocialText(if (equipped) "Equipped" else "Equip", color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) } } } @@ -815,8 +1047,10 @@ private fun PreviewPanel( selected: CosmeticUiItem?, status: String?, modifier: Modifier, + heading: Pair? = null, selectedVariantId: Int? = null, onSelectVariant: (Int) -> Unit = {}, + showBack: Boolean = false, showAuraColor: Boolean = false, auraColor: Int? = null, onPreviewAuraColor: (Int) -> Unit = {}, @@ -829,19 +1063,22 @@ private fun PreviewPanel( ) { val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped .containsKey(BodySlot.Hat) - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = PlayerPreviewSource.LocalLive, - autoSpin = false, - verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, - initialYaw = FRONT_YAW_DEG, - live = true, - ) + key(showBack) { + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = PlayerPreviewSource.LocalLive, + autoSpin = false, + verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, + initialYaw = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG, + live = true, + ) + } Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (selected != null) { + val (title, subtitle) = heading ?: selected?.let { it.name to it.collection } ?: (null to null) + if (title != null) { Column { - SocialText(selected.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - SocialText(selected.collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + subtitle?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } } } if (status != null) { @@ -2167,27 +2404,38 @@ private fun ProgressBar(fraction: Float?, modifier: Modifier) { } @Composable -private fun OnlineFeaturesDisabled() { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - SocialText( - "Cosmetics are unavailable", - color = LocalTheme.current.textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) - SocialText( - "The Terms of Service and Privacy Policy were declined, so PolyPlus does not contact its " + - "servers. Accept them under Privacy in the PolyPlus settings to use cosmetics.", - color = LocalTheme.current.textColorSecondary, - fontSize = 13.sp, - modifier = Modifier.width(420.dp), - textAlign = TextAlign.Center, - ) +private fun MinecraftCapesOnlyScreen() { + var status by remember { mutableStateOf(null) } + WardrobeScreen( + items = emptyList(), + selected = null, + status = status, + variantPicks = emptyMap(), + auraColor = null, + onPreviewAuraColor = {}, + onSelectAuraColor = {}, + onSelect = {}, + onSelectVariant = { _, _ -> }, + onEquip = {}, + onSelectMojangCape = { cape -> toggleMojangCape(cape) { status = it } }, + onOpenStore = {}, + polyPlusAvailable = false, + ) +} + +private fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { + if (MojangCapeService.busy) return + onStatus(if (cape.active) "Removing ${cape.name}..." else "Equipping ${cape.name}...") + PolyPlusClient.SCOPE.launch { + val result = if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) + ClientPlatform.runOnMain { + onStatus( + result.fold( + onSuccess = { if (cape.active) "Removed ${cape.name}." else "Equipped ${cape.name}." }, + onFailure = MojangCapeService::failureMessage, + ), + ) + } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt index 340eafae..a7aa06c4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -898,7 +898,7 @@ private fun RightColumn(modifier: Modifier, screen: Screen) { badge = groups.any { it.unread }, ) } - if (!PolyPlusMainMenuConfig.hideMainMenuCosmetics && PrivacyConsent.allowsOnlineServices()) { + if (!PolyPlusMainMenuConfig.hideMainMenuCosmetics) { PillButton( "Cosmetics", ASSETS + "diamond-01.svg", diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index 5fa45d30..9ea11331 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -15,6 +15,7 @@ import org.jetbrains.skia.ImageInfo import org.joml.Quaternionf import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.rotateBy @@ -579,7 +580,8 @@ object PlayerPreviewRenderer { if (mc.entityRenderDispatcher.getRenderer(player) == null) return val state = mc.entityRenderDispatcher.extractEntity(player, 1.0f) as? AvatarRenderState ?: return state.lightCoords = 0xF000F0 - state.showCape = source !is PlayerPreviewSource.Override || equipmentByEntityId[player.id]?.get(BodySlot.Backpack) == null + state.showCape = (source !is PlayerPreviewSource.Override || equipmentByEntityId[player.id]?.get(BodySlot.Backpack) == null) && + !hidesCape(source) capeOverride(source)?.let { state.skin = withCape(state.skin, it) } state.bodyRot = yawDeg state.yRot = 0f @@ -617,6 +619,7 @@ object PlayerPreviewRenderer { if (mc.entityRenderDispatcher.getRenderer(state) == null) return state.id = PREVIEW_ENTITY_ID if (source is PlayerPreviewSource.Override && equipment.get(BodySlot.Backpack) != null) state.showCape = false + if (hidesCape(source)) state.showCape = false state.bodyRot = yawDeg state.boundingBoxWidth = PLAYER_BB_WIDTH state.boundingBoxHeight = PLAYER_BB_HEIGHT @@ -914,13 +917,18 @@ object PlayerPreviewRenderer { is PlayerPreviewSource.Override -> source.capeCosmeticId?.let { org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache.getCapeResource(it) - } + } ?: source.mojangCape PlayerPreviewSource.LocalLive -> org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog.localEquipped().cape?.let { org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache.getCapeResource(it) - } + } ?: MojangCapeService.localCapeTexture() } + private fun hidesCape(source: PlayerPreviewSource): Boolean = + source == PlayerPreviewSource.LocalLive && + CosmeticCatalog.localEquipped().cape == null && + MojangCapeService.localCapeHidden() + //? if >= 1.21.10 { private fun withCape(skin: PlayerSkin, cape: Identifier): PlayerSkin = PlayerSkin(skin.body(), ClientAsset.ResourceTexture(cape), skin.elytra(), skin.model(), skin.secure()) @@ -1339,6 +1347,7 @@ import org.polyfrost.polyplus.client.utils.runSuspendCatching import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.render.CosmeticRenderer import org.polyfrost.polyplus.client.network.http.responses.BodySlot @@ -1623,10 +1632,11 @@ object PlayerPreviewRenderer { private fun capeFor(mc: Minecraft, source: PlayerPreviewSource): Identifier? = when (source) { is PlayerPreviewSource.Override -> if (source.equipment.get(BodySlot.Backpack) != null) null - else source.capeCosmeticId?.let(CosmeticAssetCache::getCapeResource) + else source.capeCosmeticId?.let(CosmeticAssetCache::getCapeResource) ?: source.mojangCape PlayerPreviewSource.LocalLive -> CosmeticCatalog.localEquipped().cape?.let(CosmeticAssetCache::getCapeResource) - ?: mc.player?.capeTextureLocation + ?: MojangCapeService.localCapeTexture() + ?: if (MojangCapeService.localCapeHidden()) null else mc.player?.capeTextureLocation } private fun equipmentFor(source: PlayerPreviewSource): CosmeticEquipment = when (source) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt index 51fe2612..10bde2b9 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt @@ -1,5 +1,6 @@ package org.polyfrost.polyplus.client.gui.preview +import net.minecraft.resources.Identifier import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment import org.polyfrost.polyplus.client.cosmetics.PetDefinition @@ -10,5 +11,6 @@ sealed interface PlayerPreviewSource { val equipment: CosmeticEquipment, val pet: PetDefinition? = null, val capeCosmeticId: Int? = null, + val mojangCape: Identifier? = null, ) : PlayerPreviewSource } From 577c935c4ada8c887b59fa15c68c37bb7bc75e7e Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 11:32:08 +0200 Subject: [PATCH 02/22] fix: resync local equipment when another client changes it --- .../org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt index 4e7e5c45..5a6ddb6e 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt @@ -299,6 +299,11 @@ object CosmeticSync { val uuid = UUID.fromString(packet.player) CosmeticCatalog.applyRemoteEquippedSlot(uuid, packet.slot, packet.cosmeticId) applyActiveToPlayer(uuid, packet.cosmeticId?.let(::listOf).orEmpty()) + if (uuid == ClientPlatform.localPlayerUuid() && + CosmeticCatalog.localEquipped().equipped[packet.slot] != packet.cosmeticId + ) { + PolyPlusClient.SCOPE.launch { CosmeticService.syncLocalActive() } + } } private fun handleParticleColorChanged(packet: ClientboundPacket.PlayerParticleColorChanged) { From c80da87dabf1681a19522dffc4ec404f492832bd Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 12:13:48 +0200 Subject: [PATCH 03/22] feat: rebuild the wardrobe layout and merge the store into it --- .../client/gui/PolyPlusCosmeticsScreen.kt | 2577 +---------------- .../client/gui/wardrobe/AuraColorPicker.kt | 421 +++ .../client/gui/wardrobe/WardrobeActions.kt | 124 + .../client/gui/wardrobe/WardrobeCapes.kt | 92 + .../client/gui/wardrobe/WardrobeCommon.kt | 152 + .../client/gui/wardrobe/WardrobeGrid.kt | 367 +++ .../client/gui/wardrobe/WardrobeHeader.kt | 83 + .../client/gui/wardrobe/WardrobeHistory.kt | 99 + .../client/gui/wardrobe/WardrobeLoading.kt | 122 + .../client/gui/wardrobe/WardrobeModels.kt | 170 ++ .../client/gui/wardrobe/WardrobePreview.kt | 297 ++ .../client/gui/wardrobe/WardrobeRail.kt | 105 + .../client/gui/wardrobe/WardrobeShell.kt | 237 ++ .../client/gui/wardrobe/WardrobeStoreData.kt | 176 ++ .../gui/wardrobe/WardrobeStorePanels.kt | 299 ++ .../client/gui/wardrobe/WardrobeThumbnails.kt | 201 ++ 16 files changed, 3063 insertions(+), 2459 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt index 934e0894..20560273 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt @@ -1,144 +1,50 @@ package org.polyfrost.polyplus.client.gui -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -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.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus -import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.navigation.NavigationGroup import org.polyfrost.oneconfig.internal.ui.navigation.NavigationRoute import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.polyplus.client.PolyPlusClient -import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig import org.polyfrost.polyplus.client.cosmetics.BillingService -import org.polyfrost.polyplus.client.cosmetics.CapeResolver -import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog -import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment -import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView import org.polyfrost.polyplus.client.cosmetics.CosmeticLoadProgress import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.cosmetics.CosmeticStore -import org.polyfrost.polyplus.client.cosmetics.MojangCape import org.polyfrost.polyplus.client.cosmetics.MojangCapeService -import org.polyfrost.polyplus.client.cosmetics.MojangCapeState -import org.polyfrost.polyplus.client.gui.preview.PlayerPreview -import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource -import org.polyfrost.polyplus.client.network.http.responses.BodySlot -import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo -import org.polyfrost.polyplus.client.network.http.responses.CosmeticType -import org.polyfrost.polyplus.client.network.http.responses.TransactionInfo -import org.polyfrost.polyplus.client.network.http.responses.TransactionStatus +import org.polyfrost.polyplus.client.gui.wardrobe.CartEntry +import org.polyfrost.polyplus.client.gui.wardrobe.CosmeticsLoadingScreen +import org.polyfrost.polyplus.client.gui.wardrobe.MinecraftCapesOnlyScreen +import org.polyfrost.polyplus.client.gui.wardrobe.WardrobeShell +import org.polyfrost.polyplus.client.gui.wardrobe.cartName +import org.polyfrost.polyplus.client.gui.wardrobe.checkoutFailureMessage +import org.polyfrost.polyplus.client.gui.wardrobe.cosmeticCartKey +import org.polyfrost.polyplus.client.gui.wardrobe.equip +import org.polyfrost.polyplus.client.gui.wardrobe.rememberCosmeticItems +import org.polyfrost.polyplus.client.gui.wardrobe.selectedVariantId +import org.polyfrost.polyplus.client.gui.wardrobe.toCartEntry +import org.polyfrost.polyplus.client.gui.wardrobe.unequip import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.privacy.PrivacyConsent -import java.awt.Color as AwtColor -import java.time.Duration -import java.time.Instant -import java.time.OffsetDateTime -import kotlin.math.ceil -import kotlin.math.roundToInt -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable @Serializable data object PolyPlusCosmeticsRoute @@ -189,93 +95,12 @@ fun NavGraphBuilder.polyPlusCosmeticsGraph() { } } -private enum class PolyPlusTab { - Wardrobe, - Store, - History, -} - -private data class CartEntry( - val key: String, - val name: String, - val description: String?, - val basePrice: Float?, - val finalPrice: Float?, - val discountRate: Int?, - val storeProductId: String?, - val coverAssetId: Int? = null, -) { - val free: Boolean get() = (finalPrice ?: 0f) <= 0f - - val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free -} - -private fun CosmeticStoreInfo.toCartEntry(variant: CosmeticVariantUi, storeProductId: String?): CartEntry = - CartEntry( - cosmeticCartKey(variant.id), - cartName(variant), - description, - basePrice, - finalPrice, - discountRate, - storeProductId, - coverAssetId, - ) - -private fun cosmeticCartKey(variantId: Int): String = "cosmetic-$variantId" - -private fun CosmeticStoreInfo.cartName(variant: CosmeticVariantUi): String = - if (variantList.size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name - -private data class CosmeticVariantUi(val id: Int, val name: String) - -private fun CosmeticStoreInfo.uiVariants(): List { - val byLabel = LinkedHashMap() - for (variant in variantList.sortedBy { it.variantOrder }) { - val label = variant.variantName ?: name - byLabel.getOrPut(label) { CosmeticVariantUi(variant.id, label) } - } - return byLabel.values.toList() -} - -private fun CosmeticStoreInfo.fullyOwned(ownedIds: Set): Boolean = - uiVariants().all { it.id in ownedIds } - -private fun selectedStoreVariant( - info: CosmeticStoreInfo, - picks: Map, - ownedIds: Set, -): CosmeticVariantUi { - val variants = info.uiVariants() - val picked = picks[info.id] - return variants.firstOrNull { it.id == picked } - ?: variants.firstOrNull { it.id !in ownedIds } - ?: variants.first() -} - -private fun checkoutFailureMessage(error: Throwable): String = - if (error is BillingService.AlreadyOwnedException) { - error.message ?: "You already own this item." - } else { - "Checkout failed: ${error.message}" - } - -private data class CosmeticUiItem( - val groupId: Int, - val type: CosmeticType, - val name: String, - val collection: String, - val owned: Boolean, - val equipped: Boolean, - val variants: List, - val equippedVariantId: Int?, -) { - val id: Int get() = groupId - val hasVariants: Boolean get() = variants.size > 1 -} - @Composable private fun PolyPlusCosmeticsScreen() { + DisposableEffect(Unit) { + ShellState.title = "Cosmetics" + onDispose { } + } if (!PrivacyConsent.allowsOnlineServices()) { MinecraftCapesOnlyScreen() return @@ -299,31 +124,18 @@ private fun PolyPlusCosmeticsScreen() { ) return } - var tab by remember { mutableStateOf(PolyPlusTab.Wardrobe) } - var tabResolved by remember { mutableStateOf(false) } - var showCart by remember { mutableStateOf(false) } var refreshKey by remember { mutableIntStateOf(0) } var manualRefreshKey by remember { mutableIntStateOf(0) } var status by remember { mutableStateOf(null) } val cart = remember { mutableStateListOf() } val allItems = rememberCosmeticItems(refreshKey) val ownedIds = remember(refreshKey) { CosmeticCatalog.ownedIds() } - var selectedId by remember { mutableStateOf(null) } - val selected = allItems.firstOrNull { it.id == selectedId } ?: allItems.firstOrNull() val variantPicks = remember { mutableStateMapOf() } var auraColor by remember { mutableStateOf(CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid())) } LaunchedEffect(refreshKey) { auraColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) } - LaunchedEffect(allItems, tabResolved) { - if (tabResolved || allItems.isEmpty()) return@LaunchedEffect - if (allItems.none { it.owned }) { - tab = PolyPlusTab.Store - } - tabResolved = true - } - LaunchedEffect(Unit) { PolyPlusClient.refreshCosmeticsIfNeeded() while (true) { @@ -332,8 +144,7 @@ private fun PolyPlusCosmeticsScreen() { } } - fun checkout() { - val productIds = cart.mapNotNull { it.storeProductId } + fun checkout(productIds: List) { if (productIds.isEmpty()) { status = "Nothing purchasable in your cart yet." return @@ -350,2264 +161,112 @@ private fun PolyPlusCosmeticsScreen() { } } - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Toolbar( - activeTab = tab, - cartSize = cart.size, - onWardrobe = { - tab = PolyPlusTab.Wardrobe - tabResolved = true - status = null - }, - onStore = { - tab = PolyPlusTab.Store - tabResolved = true - showCart = false - status = null - }, - onHistory = { - tab = PolyPlusTab.History - tabResolved = true - status = null - }, - onCart = { - tab = PolyPlusTab.Store - tabResolved = true - showCart = true - }, - onRefresh = { - PolyPlusClient.refreshCosmetics() - PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } - refreshKey++ - manualRefreshKey++ - status = "Refreshing cosmetic data..." - }, - ) - - when (tab) { - PolyPlusTab.Wardrobe -> WardrobeScreen( - items = allItems.filter { it.owned }, - selected = selected, - status = status, - variantPicks = variantPicks, - auraColor = auraColor, - onPreviewAuraColor = { color -> - auraColor = color - CosmeticCatalog.setParticleColor(ClientPlatform.localPlayerUuid(), color) - }, - onSelectAuraColor = { color -> - auraColor = color - status = if (color != null) "Updating aura color..." else "Clearing aura color..." - PolyPlusClient.SCOPE.launch { - val result = CosmeticService.setParticleColor(color) - ClientPlatform.runOnMain { - status = result.fold( - onSuccess = { if (color != null) "Aura color updated." else "Aura color cleared." }, - onFailure = { "Failed to set aura color: ${it.message}" }, - ) - } - } - }, - onSelect = { selectedId = it.id }, - onSelectVariant = { groupId, variantId -> - variantPicks[groupId] = variantId - val item = allItems.firstOrNull { it.groupId == groupId } - if (item != null && item.equipped && item.equippedVariantId != variantId) { - status = "Equipping ${item.name}..." - equip(item, variantId) { - refreshKey++ - status = it - } - } - }, - onEquip = { item -> - val variantId = selectedVariantId(item, variantPicks) - if (item.equipped && variantId == item.equippedVariantId) { - status = "Unequipping ${item.name}..." - unequip(item) { - refreshKey++ - status = it - } - } else if (variantId == null) { - status = "${item.name} has no variant to equip." - } else { - status = "Equipping ${item.name}..." - equip(item, variantId) { - refreshKey++ - status = it - } - } - }, - onSelectMojangCape = { cape -> toggleMojangCape(cape) { status = it } }, - onOpenStore = { - tab = PolyPlusTab.Store - tabResolved = true - showCart = false - status = null - }, - ) - - PolyPlusTab.Store -> StoreScreen( - cart = cart, - showCart = showCart, - status = status, - ownedIds = ownedIds, - onRemoveFromCart = { key -> cart.removeAll { it.key == key } }, - onBackToBrowse = { showCart = false }, - onCheckout = { checkout() }, - onAddToCart = { info, variant -> - val key = cosmeticCartKey(variant.id) - val label = info.cartName(variant) - if (variant.id in ownedIds) { - status = "You already own $label." - } else if (cart.any { it.key == key }) { - status = "$label is already in your cart." - } else { - status = "Adding $label to cart..." - PolyPlusClient.SCOPE.launch { - val view = CosmeticStore.view(variant.id).getOrNull() - ClientPlatform.runOnMain { - val priceId = view?.storeProductId - if (priceId.isNullOrBlank()) { - status = "$label isn't purchasable yet." - } else if (cart.none { it.key == key }) { - cart += info.toCartEntry(variant, priceId) - status = "$label added to cart." - } - } - } - } - }, - onBuyNow = { info, variant -> - val label = info.cartName(variant) - if (variant.id in ownedIds) { - status = "You already own $label." - return@StoreScreen - } - status = "Opening checkout..." - PolyPlusClient.SCOPE.launch { - val view = CosmeticStore.view(variant.id).getOrNull() - val priceId = view?.storeProductId - if (priceId.isNullOrBlank()) { - ClientPlatform.runOnMain { status = "$label isn't purchasable yet." } - } else { - val result = BillingService.checkoutAndOpen(listOf(priceId)) - ClientPlatform.runOnMain { - status = result.fold( - onSuccess = { "Checkout opened in your browser." }, - onFailure = ::checkoutFailureMessage, - ) - } - } - } - }, - ) - - PolyPlusTab.History -> HistoryScreen(ownedIds = ownedIds, reloadKey = manualRefreshKey) - } - } -} - -@Composable -private fun Toolbar( - activeTab: PolyPlusTab, - cartSize: Int, - onWardrobe: () -> Unit, - onStore: () -> Unit, - onHistory: () -> Unit, - onCart: () -> Unit, - onRefresh: () -> Unit, -) { - Row(modifier = Modifier.fillMaxWidth().height(33.dp), verticalAlignment = Alignment.CenterVertically) { - TabButton("Wardrobe", activeTab == PolyPlusTab.Wardrobe, onWardrobe) - Spacer(Modifier.width(8.dp)) - TabButton("Store", activeTab == PolyPlusTab.Store, onStore) - Spacer(Modifier.width(8.dp)) - TabButton("History", activeTab == PolyPlusTab.History, onHistory) - Spacer(Modifier.weight(1f)) - SmallButton("Refresh", iconPath = "refresh", primary = false, onClick = onRefresh) - Spacer(Modifier.width(8.dp)) - SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) - } -} - -@Composable -private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) { - Box( - modifier = Modifier.height(33.dp) - .clip(ppShape(7.dp)) - .background(if (selected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 14.dp), - contentAlignment = Alignment.Center, - ) { - SocialText( - label, - color = if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -@Composable -private fun SmallButton( - label: String, - iconPath: String, - primary: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier.height(33.dp) - .clip(ppShape(7.dp)) - .background(if (primary) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(iconPath, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(8.dp)) - SocialText(label, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } -} - -@Composable -private fun WardrobeScreen( - items: List, - selected: CosmeticUiItem?, - status: String?, - variantPicks: Map, - auraColor: Int?, - onPreviewAuraColor: (Int) -> Unit, - onSelectAuraColor: (Int?) -> Unit, - onSelect: (CosmeticUiItem) -> Unit, - onSelectVariant: (Int, Int) -> Unit, - onEquip: (CosmeticUiItem) -> Unit, - onSelectMojangCape: (MojangCape) -> Unit, - onOpenStore: () -> Unit, - polyPlusAvailable: Boolean = true, -) { - var selectedType by remember { mutableStateOf(CosmeticType.Cape) } - var stockedTypes by remember { - mutableStateOf(CosmeticType.entries.filter { it != CosmeticType.Unknown }) - } - LaunchedEffect(Unit) { - if (polyPlusAvailable) stockedTypes = CosmeticStore.stockedTypes() - } - - val railTypes = remember(stockedTypes, items, polyPlusAvailable) { - val owned = items.mapTo(mutableSetOf()) { it.type } - CosmeticType.entries.filter { - it != CosmeticType.Unknown && - (it == CosmeticType.Cape || (polyPlusAvailable && (it in stockedTypes || it in owned))) - } - } - - LaunchedEffect(railTypes) { - if (railTypes.isNotEmpty() && selectedType !in railTypes) { - selectedType = railTypes.first() - } - } - - val displayItems = remember(items, selectedType) { - items.filter { it.type == selectedType } - } - - LaunchedEffect(selectedType, displayItems.isEmpty()) { - if (selected?.type != selectedType) { - displayItems.firstOrNull()?.let(onSelect) - } - } - - LaunchedEffect(Unit) { - MojangCapeService.refreshIfNeeded() - } - var selectedMojangCapeId by remember { mutableStateOf(null) } - val mojangState = MojangCapeService.state - val selectedMojangCape = (mojangState as? MojangCapeState.Loaded)?.capes - ?.firstOrNull { it.id == selectedMojangCapeId } - ?.takeIf { selectedType == CosmeticType.Cape } - - val previewItem = selected?.takeIf { it.type == selectedType } ?: displayItems.firstOrNull() - Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(19.dp)) { - CategoryRail( - selected = selectedType, - onSelect = { selectedType = it }, - types = railTypes, - ) - if (selectedType == CosmeticType.Cape) { - CapeGrid( - polyPlusCapes = displayItems, - mojangState = mojangState, - mojangBusy = MojangCapeService.busy, - modifier = Modifier.width(596.dp).fillMaxHeight(), - onSelectMojangCape = { cape -> - selectedMojangCapeId = cape.id - onSelectMojangCape(cape) - }, - onSelect = { - selectedMojangCapeId = null - onSelect(it) - }, - onEquip = onEquip, - onOpenStore = onOpenStore, - polyPlusAvailable = polyPlusAvailable, - ) - } else { - CosmeticGrid( - items = displayItems, - modifier = Modifier.width(596.dp).fillMaxHeight(), - onSelect = onSelect, - onEquip = onEquip, - ) - } - PreviewPanel( - selected = previewItem?.takeIf { selectedMojangCape == null }, - heading = selectedMojangCape?.let { it.name to MOJANG_CAPE_COLLECTION }, - status = status, - selectedVariantId = previewItem?.let { selectedVariantId(it, variantPicks) }, - onSelectVariant = { variantId -> - previewItem?.let { onSelectVariant(it.groupId, variantId) } - }, - showBack = selectedType == CosmeticType.Cape, - showAuraColor = selectedType == CosmeticType.Aura, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - } -} - -private const val MOJANG_CAPE_COLLECTION = "Minecraft cape" - -@Composable -private fun CapeGrid( - polyPlusCapes: List, - mojangState: MojangCapeState, - mojangBusy: Boolean, - modifier: Modifier, - onSelectMojangCape: (MojangCape) -> Unit, - onSelect: (CosmeticUiItem) -> Unit, - onEquip: (CosmeticUiItem) -> Unit, - onOpenStore: () -> Unit, - polyPlusAvailable: Boolean, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(key = "force-capes", span = { GridItemSpan(maxLineSpan) }) { - ForceCapesControl() - } - item(key = "mojang-header", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionHeader( - title = "Minecraft capes", - note = "Everyone sees this cape, including players without PolyPlus.", - ) - } - when (mojangState) { - MojangCapeState.Idle, MojangCapeState.Loading -> - item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionNote("Loading your Minecraft capes...") - } - is MojangCapeState.Unavailable -> - item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionNote(mojangState.reason) - } - is MojangCapeState.Loaded -> - if (mojangState.capes.isEmpty()) { - item(key = "mojang-note", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionNote("You don't own any Minecraft capes.") - } - } else { - items(mojangState.capes, key = { "mojang-${it.id}" }) { cape -> - MojangCapeCard(cape, enabled = !mojangBusy, onClick = { onSelectMojangCape(cape) }) - } + WardrobeShell( + items = allItems, + ownedIds = ownedIds, + cart = cart, + status = status, + variantPicks = variantPicks, + auraColor = auraColor, + historyReloadKey = manualRefreshKey, + onStatus = { status = it }, + onRefresh = { + PolyPlusClient.refreshCosmetics() + PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } + refreshKey++ + manualRefreshKey++ + status = "Refreshing cosmetic data..." + }, + onPreviewAuraColor = { color -> + auraColor = color + CosmeticCatalog.setParticleColor(ClientPlatform.localPlayerUuid(), color) + }, + onSelectAuraColor = { color -> + auraColor = color + status = if (color != null) "Updating aura color..." else "Clearing aura color..." + PolyPlusClient.SCOPE.launch { + val result = CosmeticService.setParticleColor(color) + ClientPlatform.runOnMain { + status = result.fold( + onSuccess = { if (color != null) "Aura color updated." else "Aura color cleared." }, + onFailure = { "Failed to set aura color: ${it.message}" }, + ) } - } - item(key = "polyplus-header", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionHeader( - title = "PolyPlus capes", - note = "Players with PolyPlus see this cape instead of your Minecraft cape.", - action = "Get more".takeIf { polyPlusAvailable }, - onAction = onOpenStore, - ) - } - if (!polyPlusAvailable) { - item(key = "polyplus-note", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionNote( - "PolyPlus capes need the Terms of Service and Privacy Policy. " + - "Accept them under Privacy in the PolyPlus settings.", - ) - } - } else if (polyPlusCapes.isEmpty()) { - item(key = "polyplus-note", span = { GridItemSpan(maxLineSpan) }) { - CapeSectionNote("You don't own any PolyPlus capes yet.") - } - } else { - items(polyPlusCapes, key = { "polyplus-${it.groupId}" }) { item -> - CosmeticCard( - item = item, - onSelect = { onSelect(item) }, - onEquip = { onEquip(item) }, - ) - } - } - } -} - -@Composable -private fun ForceCapesControl() { - var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } - val modName = CapeResolver.localOverrideModName - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - SocialToggle("Force PolyPlus capes", checked = forced) { - forced = !forced - PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced - PolyPlusCosmeticsConfig.save() - } - val note = when { - modName == null -> "When another mod sets its own cape, that cape is shown unless this is on." - forced -> "$modName sets its own cape, but the capes picked here are shown instead." - else -> "Your cape is set by $modName. Turn this on to show the capes picked here." - } - SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } -} - -@Composable -private fun CapeSectionHeader( - title: String, - note: String, - action: String? = null, - onAction: () -> Unit = {}, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - if (action != null) { - Box( - modifier = Modifier.height(28.dp) - .clip(ppShape(7.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable(onClick = onAction) - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center, - ) { - SocialText(action, color = LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - } - } -} - -@Composable -private fun CapeSectionNote(text: String) { - Box( - modifier = Modifier.fillMaxWidth().height(57.dp) - .clip(ppShape(12.dp)) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.CenterStart, - ) { - SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } -} - -@Composable -private fun MojangCapeCard(cape: MojangCape, enabled: Boolean, onClick: () -> Unit) { - WardrobeCard( - name = cape.name, - collection = MOJANG_CAPE_COLLECTION, - equipped = cape.active, - enabled = enabled, - onClick = onClick, - ) { modifier -> - val textureVersion = MojangCapeService.textureVersion - val texture = remember(cape.textureHash, textureVersion) { MojangCapeService.texture(cape) } - val source = remember(texture) { - texture?.let { PlayerPreviewSource.Override(CosmeticEquipment(), mojangCape = it) } - } - CosmeticThumbnail( - source = source, - type = CosmeticType.Cape, - previewKey = "mojang-${cape.textureHash}-${texture != null}", - modifier = modifier, - ) - } -} - -@Composable -private fun HistoryScreen(ownedIds: Set, reloadKey: Int) { - var transactions by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } - var loadError by remember { mutableStateOf(null) } - - // keyed on ownership rather than the catalog poll so transactions only refetch when a purchase lands - LaunchedEffect(ownedIds, reloadKey) { - loading = true - loadError = null - BillingService.fetchTransactions() - .onSuccess { transactions = it } - .onFailure { loadError = "Couldn't load transactions: ${it.message}" } - loading = false - } - - Box( - modifier = Modifier.fillMaxSize().clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) - .padding(18.dp), - ) { - when { - loading && transactions.isEmpty() -> CenteredNote("Loading transactions...") - loadError != null && transactions.isEmpty() -> CenteredNote(loadError!!) - transactions.isEmpty() -> CenteredNote("No transactions yet.") - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { - items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } - } - } - } -} - -@Composable -private fun TransactionRow(tx: TransactionInfo) { - Row( - modifier = Modifier.fillMaxWidth() - .clip(ppShape(8.dp)) - .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) - .padding(horizontal = 13.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(13.dp), - ) { - Column(Modifier.weight(1f)) { - SocialText("Order #${tx.id}", color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - SocialText(tx.provider.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - tx.amount?.let { SocialText(money(it, tx.currency), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - SocialText( - tx.status.displayName, - color = statusColor(tx.status), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -private fun statusColor(status: TransactionStatus): Color = when (status) { - TransactionStatus.Completed -> Color(0xFF239A60) - TransactionStatus.Pending -> Color(0xFFE0A030) - TransactionStatus.Failed -> Color(0xFFFF4444) - TransactionStatus.Refunded, TransactionStatus.PartiallyRefunded, TransactionStatus.Chargeback -> Color(0xFF8A9296) - TransactionStatus.Unknown -> Color(0xFF8A9296) -} - -@Composable -private fun CategoryRail( - selected: CosmeticType, - onSelect: (CosmeticType) -> Unit, - types: List = CosmeticType.entries.filter { it != CosmeticType.Unknown }, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(58.dp)) { - types.forEach { type -> - val isSelected = type == selected - Box( - modifier = Modifier.fillMaxWidth().height(57.dp) - .clip(ppShape(7.dp)) - .background(if (isSelected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable { onSelect(type) }, - contentAlignment = Alignment.Center, - ) { - SocialText( - type.displayName, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - textAlign = TextAlign.Center, - ) } - } - } -} - -@Composable -private fun CosmeticGrid( - items: List, - modifier: Modifier, - onSelect: (CosmeticUiItem) -> Unit, - onEquip: (CosmeticUiItem) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - items(items) { item -> - CosmeticCard( - item = item, - onSelect = { onSelect(item) }, - onEquip = { onEquip(item) }, - ) - } - } -} - -@Composable -private fun CosmeticCard( - item: CosmeticUiItem, - onSelect: () -> Unit, - onEquip: () -> Unit, -) { - WardrobeCard( - name = item.name, - collection = item.collection, - equipped = item.equipped, - onClick = { onSelect(); onEquip() }, - ) { modifier -> - val (source, loadTick) = rememberCosmeticPreviewSource(item) - CosmeticThumbnail( - source = source, - type = item.type, - previewKey = "card-${item.groupId}-$loadTick", - modifier = modifier, - ) - } -} - -@Composable -private fun WardrobeCard( - name: String, - collection: String, - equipped: Boolean, - onClick: () -> Unit, - enabled: Boolean = true, - thumbnail: @Composable (Modifier) -> Unit, -) { - val border = if (equipped) Accent else LocalTheme.current.borderColor - val buttonColor = if (equipped) Accent else Color(0xB3232D32) - - Box( - modifier = Modifier.size(180.dp, 258.dp) - .alpha(if (enabled) 1f else 0.6f) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, border, ppShape(12.dp)) - .clickable(enabled = enabled, onClick = onClick), - ) { - thumbnail(Modifier.offset(17.dp, 17.dp).size(144.dp)) - CardLabel(name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) - CardLabel(collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.offset(17.dp, 192.dp).width(146.dp)) - - Row( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(36.dp) - .background(buttonColor) - .clickable(enabled = enabled, onClick = onClick), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - SocialText(if (equipped) "Equipped" else "Equip", color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - } -} - -@Composable -private fun PriceLabel(basePrice: Float?, finalPrice: Float?, discounted: Boolean) { - when { - finalPrice == null -> SocialText("—", color = LocalTheme.current.textColorSecondary, fontSize = 14.sp) - finalPrice <= 0f -> SocialText("FREE", color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) - discounted && basePrice != null -> { - SocialText(money(basePrice), color = Color(0xFFFF4444), fontSize = 10.sp, textDecoration = TextDecoration.LineThrough) - Spacer(Modifier.width(4.dp)) - SocialText(money(finalPrice), color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - else -> SocialText(money(finalPrice), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } -} - -@Composable -private fun PriceLabel(info: CosmeticStoreInfo) = PriceLabel(info.basePrice, info.finalPrice, info.discounted) - -@Composable -private fun PriceLabel(entry: CartEntry) = PriceLabel(entry.basePrice, entry.finalPrice, entry.discounted) - -@Composable -private fun PreviewPanel( - selected: CosmeticUiItem?, - status: String?, - modifier: Modifier, - heading: Pair? = null, - selectedVariantId: Int? = null, - onSelectVariant: (Int) -> Unit = {}, - showBack: Boolean = false, - showAuraColor: Boolean = false, - auraColor: Int? = null, - onPreviewAuraColor: (Int) -> Unit = {}, - onSelectAuraColor: (Int?) -> Unit = {}, -) { - Box( - modifier = modifier.clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped - .containsKey(BodySlot.Hat) - key(showBack) { - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = PlayerPreviewSource.LocalLive, - autoSpin = false, - verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, - initialYaw = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG, - live = true, - ) - } - Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - val (title, subtitle) = heading ?: selected?.let { it.name to it.collection } ?: (null to null) - if (title != null) { - Column { - SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - subtitle?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } + }, + onSelectVariant = { groupId, variantId -> + variantPicks[groupId] = variantId + val item = allItems.firstOrNull { it.groupId == groupId } + if (item != null && item.equipped && item.equippedVariantId != variantId) { + status = "Equipping ${item.name}..." + equip(item, variantId) { + refreshKey++ + status = it } } - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - } - val variants = if (selected != null && selected.hasVariants) selected.variants else emptyList() - if (variants.isNotEmpty() || showAuraColor) { - PreviewPill( - variants = variants, - selectedVariantId = selectedVariantId, - onSelectVariant = onSelectVariant, - showAuraColor = showAuraColor, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, - modifier = Modifier.align(Alignment.BottomCenter).padding(18.dp), - ) - } - } -} - -@Composable -private fun PreviewPill( - variants: List, - selectedVariantId: Int?, - onSelectVariant: (Int) -> Unit, - showAuraColor: Boolean, - auraColor: Int?, - onPreviewAuraColor: (Int) -> Unit, - onSelectAuraColor: (Int?) -> Unit, - modifier: Modifier = Modifier, -) { - var customOpen by remember { mutableStateOf(false) } - LaunchedEffect(showAuraColor) { if (!showAuraColor) customOpen = false } - - var pillHeight by remember { mutableIntStateOf(0) } - val popoverGapPx = with(LocalDensity.current) { 8.dp.roundToPx() } - - Box(modifier) { - Row( - modifier = Modifier - .onSizeChanged { pillHeight = it.height } - .clip(ppShape(PILL_RADIUS)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(PILL_RADIUS)) - .padding(horizontal = 8.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (variants.isNotEmpty()) { - VariantStrip( - variants = variants, - selectedVariantId = selectedVariantId, - onSelect = onSelectVariant, - ) - } - if (variants.isNotEmpty() && showAuraColor) { - Box(Modifier.width(1.dp).height(20.dp).background(LocalTheme.current.borderColor)) - } - if (showAuraColor) { - AuraSwatchRow( - selectedColor = auraColor, - customOpen = customOpen, - onToggleCustom = { customOpen = !customOpen }, - onCommit = onSelectAuraColor, - ) - } - } - if (customOpen && showAuraColor) { - Popup( - alignment = Alignment.TopEnd, - offset = IntOffset(0, pillHeight + popoverGapPx), - onDismissRequest = { customOpen = false }, - properties = PopupProperties(focusable = true), - ) { - AuraCustomPopover( - selectedColor = auraColor, - onPreview = onPreviewAuraColor, - onCommit = onSelectAuraColor, - ) + }, + onEquip = { item -> + val variantId = selectedVariantId(item, variantPicks) + if (item.equipped && variantId == item.equippedVariantId) { + status = "Unequipping ${item.name}..." + unequip(item) { + refreshKey++ + status = it + } + } else if (variantId == null) { + status = "${item.name} has no variant to equip." + } else { + status = "Equipping ${item.name}..." + equip(item, variantId) { + refreshKey++ + status = it + } } - } - } -} - -@Composable -private fun RowScope.VariantStrip( - variants: List, - selectedVariantId: Int?, - onSelect: (Int) -> Unit, -) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - val wheelStep = with(LocalDensity.current) { VARIANT_WHEEL_STEP.toPx() } - val arrowStep = with(LocalDensity.current) { VARIANT_ARROW_STEP.toPx() } - val overflows = scrollState.maxValue > 0 && scrollState.maxValue != Int.MAX_VALUE - - if (overflows) { - ScrollArrow( - iconName = "left-arrow", - enabled = scrollState.value > 0, - onClick = { scope.launch { scrollState.animateScrollBy(-arrowStep) } }, - ) - } - Row( - modifier = Modifier.weight(1f, fill = false) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type != PointerEventType.Scroll) continue - val delta = event.changes.first().scrollDelta - if (delta.x == 0f && delta.y != 0f) { - scope.launch { scrollState.scrollBy(delta.y * wheelStep) } - event.changes.forEach { it.consume() } + }, + onAddToCart = { info, variant -> + val key = cosmeticCartKey(variant.id) + val label = info.cartName(variant) + if (variant.id in ownedIds) { + status = "You already own $label." + } else if (cart.any { it.key == key }) { + status = "$label is already in your cart." + } else { + status = "Adding $label to cart..." + PolyPlusClient.SCOPE.launch { + val view = CosmeticStore.view(variant.id).getOrNull() + ClientPlatform.runOnMain { + val priceId = view?.storeProductId + if (priceId.isNullOrBlank()) { + status = "$label isn't purchasable yet." + } else if (cart.none { it.key == key }) { + cart += info.toCartEntry(variant, priceId) + status = "$label added to cart." } } } } - .horizontalScroll(scrollState), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - for (variant in variants) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.componentBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) - } - } - } - if (overflows) { - ScrollArrow( - iconName = "right-arrow", - enabled = scrollState.value < scrollState.maxValue, - onClick = { scope.launch { scrollState.animateScrollBy(arrowStep) } }, - ) - } -} - -@Composable -private fun ScrollArrow(iconName: String, enabled: Boolean, onClick: () -> Unit) { - Box( - modifier = Modifier.size(18.dp) - .clip(ppShape(4.dp)) - .background(LocalTheme.current.componentBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(4.dp)) - .alpha(if (enabled) 1f else 0.35f) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon(iconName, color = LocalTheme.current.textColor, modifier = Modifier.size(10.dp)) - } -} - -@Composable -private fun AuraSwatchRow( - selectedColor: Int?, - customOpen: Boolean, - onToggleCustom: () -> Unit, - onCommit: (Int?) -> Unit, -) { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - AuraDot( - color = Color(DEFAULT_AURA_ARGB), - isSelected = selectedColor == null, - onClick = { onCommit(null) }, - ) - for (color in AURA_PRESETS) { - AuraDot( - color = Color(color), - isSelected = selectedColor == color, - onClick = { onCommit(color) }, - ) - } - val isCustom = selectedColor != null && selectedColor !in AURA_PRESETS - AuraDot( - color = if (isCustom) Color(selectedColor!!) else LocalTheme.current.componentBackground, - isSelected = isCustom || customOpen, - onClick = onToggleCustom, - ) { - if (!isCustom) { - SocialText("+", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) - } - } - } -} - -@Composable -private fun AuraDot( - color: Color, - isSelected: Boolean, - onClick: () -> Unit, - content: @Composable () -> Unit = {}, -) { - Box( - modifier = Modifier.size(18.dp) - .clip(ppShape(4.dp)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Checkerboard(Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(color)) - Box( - Modifier.fillMaxSize().border( - width = if (isSelected) 2.dp else 1.dp, - color = if (isSelected) Accent else LocalTheme.current.borderColor, - shape = ppShape(4.dp), - ), - ) - content() - } -} - -@Composable -private fun VariantPicker( - variants: List, - selectedVariantId: Int?, - onSelect: (Int) -> Unit, - ownedVariantIds: Set = emptySet(), -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.width(220.dp)) { - SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - for (row in variants.chunked(2)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - for (variant in row) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .alpha(if (variant.id in ownedVariantIds && !isSelected) 0.55f else 1f) - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) - } - } - } - } - } -} - -private val PILL_RADIUS = 18.dp - -private val VARIANT_WHEEL_STEP = 48.dp - -private val VARIANT_ARROW_STEP = 96.dp - -private val AURA_COLORS: List = listOf( - 0xFFFF4444.toInt(), - 0xFFFF9E3D.toInt(), - 0xFFF4D03F.toInt(), - 0xFF2ECC71.toInt(), - 0xFF1ABC9C.toInt(), - 0xFF3DA5FF.toInt(), - 0xFF6C5CE7.toInt(), - 0xFFB05CFF.toInt(), - 0xFFFF6FD8.toInt(), - 0xFFFFFFFF.toInt(), - 0xFF3A3F43.toInt(), - 0xFF9BA2A6.toInt(), -) - -private const val DEFAULT_AURA_ARGB: Int = -1 - -private val AURA_PRESETS: List = AURA_COLORS.filter { it != DEFAULT_AURA_ARGB } - -/** Converts an ARGB int to a HSB float array (hue in degrees, saturation and brightness as a float between 0 and 1). */ -private fun argbToHsb(argb: Int): FloatArray = - AwtColor.RGBtoHSB((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF, null) - .also { it[0] *= 360f } - -private fun hsbToColor(hue: Float, saturation: Float, brightness: Float): Color = - Color.hsv(hue, saturation, brightness) - -private fun argbHex(argb: Int): String = - "%02X%02X%02X".format((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF) - -private fun hexToArgb(hex: String, alpha: Float): Int? { - val h = hex.removePrefix("#").trim() - if (h.length != 6) return null - return try { - ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or Integer.parseInt(h, 16) - } catch (_: Exception) { - null - } -} - -private fun Color.toArgb(alpha: Float): Int = - ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or - ((red.coerceIn(0f, 1f) * 255f).roundToInt() shl 16) or - ((green.coerceIn(0f, 1f) * 255f).roundToInt() shl 8) or - (blue.coerceIn(0f, 1f) * 255f).roundToInt() - -private class AuraPickerState(argb: Int) { - var hue by mutableFloatStateOf(0f) - var saturation by mutableFloatStateOf(0f) - var brightness by mutableFloatStateOf(0f) - var alpha by mutableFloatStateOf(1f) - - var lastEmitted: Int? = null - - init { - applyFrom(argb) - } - - fun applyFrom(argb: Int) { - val hsb = argbToHsb(argb) - hue = hsb[0] - saturation = hsb[1] - brightness = hsb[2] - alpha = ((argb ushr 24) and 0xFF) / 255f - } - - fun currentArgb(): Int = hsbToColor(hue, saturation, brightness).toArgb(alpha) -} - -@Composable -private fun Checkerboard(modifier: Modifier, cell: Dp = 4.dp) { - Box( - modifier = modifier.drawBehind { - val step = cell.toPx() - val cols = ceil(size.width / step).toInt() - val rows = ceil(size.height / step).toInt() - for (x in 0..cols) { - for (y in 0..rows) { - drawRect( - color = if ((x + y) % 2 == 0) Color(0xFF5F6568) else Color(0xFF3D4245), - topLeft = Offset(x * step, y * step), - size = Size(step, step), - ) - } + }, + onBuyNow = { info, variant -> + val label = info.cartName(variant) + if (variant.id in ownedIds) { + status = "You already own $label." + } else { + status = "Opening checkout..." + PolyPlusClient.SCOPE.launch { + val priceId = CosmeticStore.view(variant.id).getOrNull()?.storeProductId + if (priceId.isNullOrBlank()) { + ClientPlatform.runOnMain { status = "$label isn't purchasable yet." } + } else { + ClientPlatform.runOnMain { checkout(listOf(priceId)) } + } + } } }, + onBuyBundle = { bundle -> + val priceId = bundle.storeProductId + if (priceId.isNullOrBlank()) status = "${bundle.name} isn't purchasable yet." else checkout(listOf(priceId)) + }, + onRemoveFromCart = { key -> cart.removeAll { it.key == key } }, + onCheckout = { checkout(cart.mapNotNull { it.storeProductId }) }, ) } - -@Composable -private fun AuraCustomPopover( - selectedColor: Int?, - onPreview: (Int) -> Unit, - onCommit: (Int?) -> Unit, - modifier: Modifier = Modifier, -) { - val state = remember { AuraPickerState(selectedColor ?: 0xFFFFFFFF.toInt()) } - var hexText by remember { mutableStateOf(argbHex(state.currentArgb())) } - LaunchedEffect(selectedColor) { - if (selectedColor != null && selectedColor != state.lastEmitted) { - state.applyFrom(selectedColor) - hexText = argbHex(selectedColor) - } - } - - fun emitPreview() { - val argb = state.currentArgb() - state.lastEmitted = argb - hexText = argbHex(argb) - onPreview(argb) - } - - fun emitCommit() { - val argb = state.currentArgb() - state.lastEmitted = argb - onCommit(argb) - } - - val hue = state.hue - val saturation = state.saturation - val brightness = state.brightness - val alpha = state.alpha - val current = hsbToColor(hue, saturation, brightness) - - Column( - modifier = modifier.width(190.dp) - .clip(ppShape(10.dp)) - .background(LocalTheme.current.popupBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(10.dp)) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SocialText("Aura Color", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - - var paneSize by remember { mutableStateOf(Size.Zero) } - Box( - modifier = Modifier.fillMaxWidth().height(120.dp) - .clip(ppShape(6.dp)) - .onSizeChanged { paneSize = Size(it.width.toFloat(), it.height.toFloat()) } - .drawWithCache { - val hueColor = hsbToColor(hue, 1f, 1f) - val horizontal = Brush.horizontalGradient(listOf(Color.White, hueColor)) - val vertical = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) - onDrawBehind { - drawRect(horizontal) - drawRect(vertical) - } - } - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float, y: Float) { - state.saturation = (x / paneSize.width).coerceIn(0f, 1f) - state.brightness = 1f - (y / paneSize.height).coerceIn(0f, 1f) - emitPreview() - } - update(down.position.x, down.position.y) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x, ch.position.y) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - val selX = (saturation * paneSize.width).coerceIn(0f, paneSize.width) - val selY = ((1f - brightness) * paneSize.height).coerceIn(0f, paneSize.height) - Box( - modifier = Modifier - .offset { IntOffset((selX - 7.dp.toPx()).roundToInt(), (selY - 7.dp.toPx()).roundToInt()) } - .size(14.dp) - .clip(ppShape(7.dp)) - .background(current) - .border(2.dp, Color.White, ppShape(7.dp)), - ) - } - - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .clip(ppShape(4.dp)) - .drawWithCache { - val gradient = Brush.horizontalGradient( - listOf(Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red), - ) - onDrawBehind { drawRect(gradient) } - } - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float) { - state.hue = ((x / size.width) * 360f).coerceIn(0f, 360f) - emitPreview() - } - update(down.position.x) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - var barWidth by remember { mutableStateOf(0f) } - Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { barWidth = it.width.toFloat() }) - Box( - modifier = Modifier.align(Alignment.CenterStart) - .offset { IntOffset(((hue / 360f) * barWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } - .size(10.dp) - .clip(ppShape(5.dp)) - .background(Color.White) - .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), - ) - } - - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .clip(ppShape(4.dp)) - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float) { - state.alpha = (x / size.width).coerceIn(0f, 1f) - emitPreview() - } - update(down.position.x) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - Checkerboard(Modifier.fillMaxWidth().height(14.dp)) - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .drawWithCache { - val gradient = Brush.horizontalGradient(listOf(current.copy(alpha = 0f), current)) - onDrawBehind { drawRect(gradient) } - }, - ) - var alphaBarWidth by remember { mutableStateOf(0f) } - Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { alphaBarWidth = it.width.toFloat() }) - Box( - modifier = Modifier.align(Alignment.CenterStart) - .offset { IntOffset((alpha * alphaBarWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } - .size(10.dp) - .clip(ppShape(5.dp)) - .background(Color.White) - .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), - ) - } - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(modifier = Modifier.size(28.dp).clip(ppShape(5.dp))) { - Checkerboard(Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(current.copy(alpha = alpha))) - Box(Modifier.fillMaxSize().border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp))) - } - BasicTextField( - value = hexText, - onValueChange = { input -> - val filtered = input.filter { it.isLetterOrDigit() }.take(6) - hexText = filtered - hexToArgb(filtered, state.alpha)?.let { argb -> - state.applyFrom(argb) - state.lastEmitted = argb - onCommit(argb) - } - }, - singleLine = true, - textStyle = TextStyle( - color = LocalTheme.current.textColor, - fontSize = 12.sp, - fontFamily = LocalTheme.current.typography.family, - ), - cursorBrush = SolidColor(LocalTheme.current.textColor), - modifier = Modifier.weight(1f) - .clip(ppShape(5.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp) - .trackTextInputFocus(), - decorationBox = { inner -> - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - SocialText("#", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - inner() - } - }, - ) - } - - } -} - -@Composable -private fun CartPanel( - items: List, - status: String?, - modifier: Modifier, - onRemove: (String) -> Unit, - onCheckout: () -> Unit, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(11.dp)) { - if (items.isEmpty()) { - CenteredNote("Your cart is empty.") - return@Column - } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(11.dp), - ) { - items(items, key = { it.key }) { item -> - CartRow(item = item, onRemove = { onRemove(item.key) }) - } - } - - val subtotal = items.sumOf { (it.basePrice ?: 0f).toDouble() }.toFloat() - val total = items.sumOf { (it.finalPrice ?: it.basePrice ?: 0f).toDouble() }.toFloat() - val discount = (subtotal - total).coerceAtLeast(0f) - RowTotals("Subtotal", money(subtotal), LocalTheme.current.textColor) - if (discount > 0f) { - RowTotals("Discounts", "-${money(discount)}", Color(0xFF239A60)) - } - RowTotals("Total", if (total <= 0f) "FREE" else money(total), Color.White, large = true) - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - SmallButton( - label = "Checkout ${items.size} item${if (items.size == 1) "" else "s"}", - iconPath = "assets/polyplus/ico/shopping-cart/0.svg", - primary = true, - onClick = onCheckout, - ) - } -} - -@Composable -private fun CartRow(item: CartEntry, onRemove: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().height(81.dp) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) - .padding(horizontal = 13.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(13.dp), - ) { - CoverThumbnail(item.coverAssetId, Modifier.size(58.dp)) - Column(Modifier.weight(1f)) { - SocialText(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - item.description?.takeIf { it.isNotBlank() }?.let { - SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - } - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(item) } - if (item.discounted) SocialText("SAVE ${item.discountRate}%", color = Color(0xFF239A60), fontSize = 12.sp) - SocialText("Remove", color = Color(0xFFFF4444), fontSize = 12.sp, modifier = Modifier.clickable(onClick = onRemove)) - } - } -} - -@Composable -private fun RowTotals(label: String, value: String, color: Color, large: Boolean = false) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - SocialText(label, color = LocalTheme.current.textColor, fontSize = if (large) 18.sp else 12.sp, modifier = Modifier.weight(1f)) - SocialText(value, color = color, fontSize = if (large) 22.sp else 12.sp, fontWeight = if (large) FontWeight.SemiBold else FontWeight.Normal) - } -} - -@Composable -private fun CenteredNote(text: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 13.sp, textAlign = TextAlign.Center) - } -} - -@Composable -private fun CosmeticThumbnail( - source: PlayerPreviewSource?, - type: CosmeticType, - previewKey: String, - modifier: Modifier, -) { - val framing = cosmeticPreviewFraming(type) - Box(modifier) { - CheckerThumbnail(Modifier.fillMaxSize()) - if (source != null) { - PlayerPreview( - Modifier.fillMaxSize(), - source = source, - autoSpin = false, - allowDrag = false, - modelScale = framing.modelScale, - verticalAnchor = framing.verticalAnchor, - initialYaw = framing.yawDeg, - previewKey = previewKey, - live = needsLivePreview(type, source), - ) - } - } -} - -private fun needsLivePreview(type: CosmeticType, source: PlayerPreviewSource) = when (type) { - CosmeticType.Aura -> true - CosmeticType.Cape -> - (source as? PlayerPreviewSource.Override)?.capeCosmeticId?.let(CosmeticAssetCache::isCapeAnimated) == true - else -> false -} - -private data class PreviewFraming(val yawDeg: Float, val modelScale: Float, val verticalAnchor: Float) - -private const val FRONT_YAW_DEG = 180f + 22.9f -private const val BACK_YAW_DEG = 22.9f -private const val STORE_PREVIEW_FADE_FRACTION = 0.22f - -private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (type) { - CosmeticType.Hat, CosmeticType.Glasses -> PreviewFraming(FRONT_YAW_DEG, 1.0f, 1.5f) - CosmeticType.Boots -> PreviewFraming(FRONT_YAW_DEG, 0.85f, 0.26f) - CosmeticType.Shoulder -> PreviewFraming(FRONT_YAW_DEG, 0.58f, 0.62f) - CosmeticType.Wings -> PreviewFraming(BACK_YAW_DEG, 0.5f, 0.5f) - CosmeticType.Backpack -> PreviewFraming(BACK_YAW_DEG, 0.55f, 0.55f) - CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.42f, 0.5f) - CosmeticType.Aura -> PreviewFraming(FRONT_YAW_DEG, 0.5f, 0.5f) - CosmeticType.Glove -> PreviewFraming(FRONT_YAW_DEG, 0.7f, 0.42f) - else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) -} - -@Composable -private fun rememberCosmeticPreviewSource(item: CosmeticUiItem): Pair { - val cosmeticId = item.equippedVariantId ?: item.variants.firstOrNull()?.id ?: return null to 0 - return rememberCosmeticPreviewSource(cosmeticId, item.type) -} - -@Composable -private fun rememberCosmeticPreviewSource(cosmeticId: Int, type: CosmeticType): Pair { - val isCape = type == CosmeticType.Cape - val isPet = type == CosmeticType.Pet - - var previewId by remember(cosmeticId) { mutableIntStateOf(cosmeticId) } - val loaded = CosmeticAssetCache.installs.let { isPreviewAssetLoaded(previewId, isCape, isPet) } - // a trim can evict a preview's assets while the screen is closed, so it reloads them when shown again - LaunchedEffect(cosmeticId, loaded) { - val slim = ClientPlatform.runOnMainSync { ClientPlatform.localSkinSlim() } - val id = CosmeticCatalog.resolveVariantForSkin(cosmeticId, slim) - previewId = id - if (!isPreviewAssetLoaded(id, isCape, isPet)) CosmeticAssetCache.ensureCosmeticLoaded(id) - } - - val loadTick = (if (loaded) 1 else 0) + (if (isCape && CosmeticAssetCache.isCapeAnimated(previewId)) 2 else 0) - - val source = remember(previewId, loadTick, isCape, isPet) { - when { - isCape -> PlayerPreviewSource.Override(CosmeticEquipment(), capeCosmeticId = previewId) - isPet -> { - val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) - if (attached != null) { - val equipment = CosmeticEquipment() - equipment.equip(attached) - PlayerPreviewSource.Override(equipment) - } else { - val pet = CosmeticAssetCache.getPetDefinition(previewId) ?: return@remember null - PlayerPreviewSource.Override(CosmeticEquipment(), pet = pet) - } - } - else -> { - val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) ?: return@remember null - val equipment = CosmeticEquipment() - equipment.equip(attached) - PlayerPreviewSource.Override(equipment) - } - } - } - - return source to loadTick -} - -@Composable -private fun BoxScope.OwnershipBadge(owned: Boolean, createdAt: String, modifier: Modifier) { - val label = when { - owned -> "OWNED" - isNewItem(createdAt) -> "NEW" - else -> return - } - Box( - modifier = modifier.align(Alignment.TopStart) - .clip(ppShape(4.dp)) - .background(if (owned) Color(0xFF239A60) else Accent) - .padding(horizontal = 7.dp, vertical = 2.dp), - ) { - SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) - } -} - -private fun isPreviewAssetLoaded(id: Int, isCape: Boolean, isPet: Boolean): Boolean = when { - isCape -> CosmeticAssetCache.isCapeLoaded(id) - isPet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null - else -> CosmeticAssetCache.getAttachedCosmetic(id) != null -} - -private fun isNewItem(createdAt: String): Boolean { - if (createdAt.isBlank()) return false - return runCatching { - val created = OffsetDateTime.parse(createdAt).toInstant() - created.isAfter(Instant.now().minus(Duration.ofDays(7))) - }.getOrDefault(false) -} - -private const val STORE_PAGE_SIZE = CosmeticStore.MAX_PAGE_SIZE - -private const val STORE_PREFETCH_DISTANCE = 6 - -@Composable -private fun StoreScreen( - cart: List, - showCart: Boolean, - status: String?, - ownedIds: Set, - onRemoveFromCart: (String) -> Unit, - onBackToBrowse: () -> Unit, - onCheckout: () -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, -) { - val variantPicks = remember { mutableStateMapOf() } - var query by remember { mutableStateOf("") } - var submitted by remember { mutableStateOf("") } - var selectedType by remember { mutableStateOf(CosmeticType.Cape) } - var nextPage by remember { mutableIntStateOf(1) } - var results by remember { mutableStateOf>(emptyList()) } - var hasMore by remember { mutableStateOf(true) } - var loading by remember { mutableStateOf(true) } - var loadError by remember { mutableStateOf(null) } - var selectedId by remember { mutableStateOf(null) } - var stockedTypes by remember { - mutableStateOf(CosmeticType.entries.filter { it != CosmeticType.Unknown }) - } - val gridState = rememberLazyGridState() - - LaunchedEffect(Unit) { - stockedTypes = CosmeticStore.stockedTypes() - } - - LaunchedEffect(submitted, selectedType, nextPage) { - loading = true - loadError = null - CosmeticStore.search( - page = nextPage, - perPage = STORE_PAGE_SIZE, - text = submitted.ifBlank { null }, - types = listOf(selectedType.serializedName), - ) - .onSuccess { resp -> - results = if (nextPage == 1) resp.results else results + resp.results - hasMore = nextPage < resp.pagination.totalPages - } - .onFailure { - loadError = "Couldn't load store: ${it.message}" - hasMore = false - } - loading = false - } - - val items = remember(results, ownedIds) { - results.sortedBy { if (it.fullyOwned(ownedIds)) 1 else 0 } - } - - val nearEnd by remember { - derivedStateOf { - val info = gridState.layoutInfo - val last = info.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true - last.index >= info.totalItemsCount - STORE_PREFETCH_DISTANCE - } - } - LaunchedEffect(nearEnd, hasMore, loading) { - if (nearEnd && hasMore && !loading) nextPage++ - } - - LaunchedEffect(items) { - if (selectedId == null || items.none { it.id == selectedId }) { - selectedId = items.firstOrNull()?.id - } - } - - val cartKeys = cart.map { it.key }.toSet() - val selected = items.firstOrNull { it.id == selectedId } - - fun reset() { - results = emptyList() - hasMore = true - nextPage = 1 - } - - fun submit() { - submitted = query.trim() - reset() - } - - LaunchedEffect(stockedTypes) { - if (stockedTypes.isNotEmpty() && selectedType !in stockedTypes) { - selectedType = stockedTypes.first() - reset() - } - } - - Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(19.dp)) { - CategoryRail( - selected = selectedType, - onSelect = { - if (it != selectedType) { - selectedType = it - reset() - } - }, - types = stockedTypes, - ) - Column(modifier = Modifier.width(596.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(11.dp)) { - StoreSearchBar(query = query, onQueryChange = { query = it }, onSubmit = { submit() }) - when { - loading && items.isEmpty() -> CenteredNote("Loading cosmetics...") - loadError != null && items.isEmpty() -> CenteredNote(loadError!!) - items.isEmpty() -> CenteredNote( - if (submitted.isBlank()) { - "No ${selectedType.displayName.lowercase()} cosmetics available yet." - } else { - "No cosmetics match \"$submitted\"." - }, - ) - else -> StoreGrid( - items = items, - cartKeys = cartKeys, - ownedIds = ownedIds, - variantPicks = variantPicks, - state = gridState, - modifier = Modifier.weight(1f).fillMaxWidth(), - onSelect = { selectedId = it.id }, - onAddToCart = onAddToCart, - ) - } - } - - val selectedVariant = selected?.let { selectedStoreVariant(it, variantPicks, ownedIds) } - Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(14.dp)) { - if (showCart) { - SmallButton("Back to store", iconPath = "left-arrow", primary = false, onClick = onBackToBrowse) - CartPanel( - items = cart, - status = status, - modifier = Modifier.fillMaxSize(), - onRemove = onRemoveFromCart, - onCheckout = onCheckout, - ) - } else { - StoreDetailPanel( - info = selected, - variant = selectedVariant, - status = status, - ownedIds = ownedIds, - inCart = selectedVariant?.let { cosmeticCartKey(it.id) in cartKeys } ?: false, - onSelectVariant = { variantId -> selected?.let { variantPicks[it.id] = variantId } }, - onAddToCart = onAddToCart, - onBuyNow = onBuyNow, - modifier = Modifier.fillMaxSize(), - ) - } - } - } -} - -@Composable -private fun StoreSearchBar(query: String, onQueryChange: (String) -> Unit, onSubmit: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().height(33.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = TextStyle( - color = LocalTheme.current.textColor, - fontSize = 13.sp, - fontFamily = LocalTheme.current.typography.family, - ), - cursorBrush = SolidColor(LocalTheme.current.textColor), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSubmit() }), - modifier = Modifier.weight(1f).fillMaxHeight() - .clip(ppShape(7.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .padding(horizontal = 12.dp) - .trackTextInputFocus(), - decorationBox = { inner -> - Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f)) { - if (query.isEmpty()) { - SocialText("Search cosmetics...", color = LocalTheme.current.textColorSecondary, fontSize = 13.sp) - } - inner() - } - } - }, - ) - SmallButton("Search", iconPath = "assets/polyplus/ico/search.svg", primary = true, onClick = onSubmit) - } -} - -@Composable -private fun StoreGrid( - items: List, - cartKeys: Set, - ownedIds: Set, - variantPicks: Map, - state: LazyGridState, - modifier: Modifier, - onSelect: (CosmeticStoreInfo) -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - state = state, - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - items(items, key = { it.id }) { info -> - val variant = selectedStoreVariant(info, variantPicks, ownedIds) - StoreCard( - info = info, - variant = variant, - owned = info.fullyOwned(ownedIds), - variantOwned = variant.id in ownedIds, - inCart = cosmeticCartKey(variant.id) in cartKeys, - onSelect = { onSelect(info) }, - onAddToCart = { onAddToCart(info, variant) }, - ) - } - } -} - -@Composable -private fun StoreCard( - info: CosmeticStoreInfo, - variant: CosmeticVariantUi, - owned: Boolean, - variantOwned: Boolean, - inCart: Boolean, - onSelect: () -> Unit, - onAddToCart: () -> Unit, -) { - val variantCount = info.uiVariants().size - val border = when { - inCart -> Accent - owned -> LocalTheme.current.borderColor - info.discounted -> Color(0xFF239A60) - else -> LocalTheme.current.borderColor - } - val buttonColor = if (inCart) Accent else Color(0xB3232D32) - - Box( - modifier = Modifier.size(180.dp, 258.dp) - .alpha(if (owned) 0.6f else 1f) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, border, ppShape(12.dp)) - .clickable(onClick = onSelect), - ) { - val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) - CosmeticThumbnail( - source = source, - type = info.type, - previewKey = "store-${variant.id}-$loadTick", - modifier = Modifier.offset(17.dp, 17.dp).size(144.dp), - ) - CardLabel(info.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) - Row(modifier = Modifier.offset(17.dp, 193.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - PriceLabel(info) - if (variantCount > 1) { - SocialText("$variantCount variants", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) - } - } - - OwnershipBadge(owned, info.createdAt, Modifier.offset(x = 8.dp, y = 8.dp)) - - if (info.discounted && !owned) { - Box( - modifier = Modifier.align(Alignment.TopEnd).size(66.dp, 21.dp) - .background(Color(0xFF239A60), ppShapeOf(bottomStart = 4.dp, topEnd = 12.dp)), - contentAlignment = Alignment.Center, - ) { - SocialText("${info.discountRate}% OFF", color = LocalTheme.current.accentTextColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - } - - Row( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(36.dp) - .background(buttonColor) - .clickable(onClick = if (inCart || variantOwned) onSelect else onAddToCart), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val label = when { - variantOwned && owned -> "Owned" - variantOwned -> "Owned - see variants" - inCart -> "In cart" - else -> "Add to cart" - } - SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - } -} - -@Composable -private fun StoreDetailPanel( - info: CosmeticStoreInfo?, - variant: CosmeticVariantUi?, - status: String?, - ownedIds: Set, - inCart: Boolean, - onSelectVariant: (Int) -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - modifier: Modifier, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { - Box( - modifier = Modifier.fillMaxWidth().height(330.dp) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - if (info == null || variant == null) { - CenteredNote("Select a cosmetic to preview it.") - } else { - val (source, _) = rememberCosmeticPreviewSource(variant.id, info.type) - val fades = info.type != CosmeticType.Boots - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = source ?: PlayerPreviewSource.LocalLive, - autoSpin = false, - bottomFadeFraction = if (fades) STORE_PREVIEW_FADE_FRACTION else 0f, - verticalAnchor = when (info.type) { - CosmeticType.Hat -> 0.70f - CosmeticType.Boots -> 0.44f - else -> 0.56f - }, - initialYaw = FRONT_YAW_DEG, - previewKey = "store-detail-${variant.id}", - live = true, - ) - OwnershipBadge(variant.id in ownedIds, info.createdAt, Modifier.padding(12.dp)) - } - } - - Box( - modifier = Modifier.weight(1f).fillMaxWidth() - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - if (info == null || variant == null) { - CenteredNote("Browse the store to buy individual cosmetics.") - } else { - val variants = info.uiVariants() - Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Column( - modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - SocialText(info.type.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - SocialText(info.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(info) } - if (variants.size > 1) { - VariantPicker( - variants = variants, - selectedVariantId = variant.id, - onSelect = onSelectVariant, - ownedVariantIds = ownedIds, - ) - } - if (info.tags.all.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - for (tag in info.tags.all.take(4)) { - Box( - modifier = Modifier.clip(ppShape(5.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - SocialText(tag.replaceFirstChar { it.uppercase() }, color = LocalTheme.current.textColor, fontSize = 11.sp) - } - } - } - } - info.description?.takeIf { it.isNotBlank() }?.let { - SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - } - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - if (variant.id in ownedIds) { - SocialText( - "You already own this cosmetic.", - color = LocalTheme.current.textColorSecondary, - fontSize = 12.sp, - ) - } else { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SmallButton( - label = if (inCart) "In cart" else "Add to cart", - iconPath = "assets/polyplus/ico/shopping-cart/0.svg", - primary = false, - onClick = { if (!inCart) onAddToCart(info, variant) }, - ) - SmallButton( - label = "Buy now", - iconPath = "assets/polyplus/ico/shopping-bag.svg", - primary = true, - onClick = { onBuyNow(info, variant) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun CoverThumbnail(coverAssetId: Int?, modifier: Modifier) { - val cover = rememberCoverImage(coverAssetId) - Box(modifier) { - CheckerThumbnail(Modifier.fillMaxSize()) - if (cover != null) { - Image( - bitmap = cover, - contentDescription = null, - filterQuality = FilterQuality.None, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().clip(ppShape(5.dp)), - ) - } - } -} - -@Composable -private fun CheckerThumbnail(modifier: Modifier) { - Checkerboard( - modifier = modifier.clip(ppShape(5.dp)) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)), - cell = 9.dp, - ) -} - -@Composable -private fun CosmeticsLoadingScreen( - state: CosmeticLoadProgress.Snapshot, - onRetry: () -> Unit, -) { - val failed = state.failure != null - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), - ) { - SocialText( - if (failed) "Couldn't load cosmetics" else "Loading cosmetics", - color = LocalTheme.current.textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) - SocialText( - if (failed) { - "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." - } else { - state.label - }, - color = LocalTheme.current.textColorSecondary, - fontSize = 13.sp, - modifier = Modifier.width(360.dp), - textAlign = TextAlign.Center, - ) - if (failed) { - Spacer(Modifier.height(2.dp)) - SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) - } else { - ProgressBar(state.fraction, Modifier.width(360.dp)) - } - } -} - -private const val SWEEP_WIDTH_FRACTION = 0.35f - -@Composable -private fun ProgressBar(fraction: Float?, modifier: Modifier) { - val track = LocalTheme.current.chipBackground - val shape = ppShape(4.dp) - Box( - modifier - .height(8.dp) - .clip(shape) - .background(track) - .border(1.dp, LocalTheme.current.borderColor, shape), - ) { - if (fraction != null) { - val animated by animateFloatAsState(fraction.coerceIn(0f, 1f), animationSpec = tween(220)) - Box( - Modifier - .fillMaxHeight() - .fillMaxWidth(animated) - .clip(shape) - .background(Accent), - ) - } else { - val transition = rememberInfiniteTransition(label = "cosmeticsLoadingSweep") - val head by transition.animateFloat( - initialValue = -SWEEP_WIDTH_FRACTION, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1_100, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "cosmeticsLoadingSweepHead", - ) - BoxWithConstraints(Modifier.fillMaxSize()) { - val width = maxWidth - Box( - Modifier - .offset(x = width * head) - .fillMaxHeight() - .width(width * SWEEP_WIDTH_FRACTION) - .clip(shape) - .background(Accent), - ) - } - } - } -} - -@Composable -private fun MinecraftCapesOnlyScreen() { - var status by remember { mutableStateOf(null) } - WardrobeScreen( - items = emptyList(), - selected = null, - status = status, - variantPicks = emptyMap(), - auraColor = null, - onPreviewAuraColor = {}, - onSelectAuraColor = {}, - onSelect = {}, - onSelectVariant = { _, _ -> }, - onEquip = {}, - onSelectMojangCape = { cape -> toggleMojangCape(cape) { status = it } }, - onOpenStore = {}, - polyPlusAvailable = false, - ) -} - -private fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { - if (MojangCapeService.busy) return - onStatus(if (cape.active) "Removing ${cape.name}..." else "Equipping ${cape.name}...") - PolyPlusClient.SCOPE.launch { - val result = if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) - ClientPlatform.runOnMain { - onStatus( - result.fold( - onSuccess = { if (cape.active) "Removed ${cape.name}." else "Equipped ${cape.name}." }, - onFailure = MojangCapeService::failureMessage, - ), - ) - } - } -} - -@Composable -private fun CardLabel( - text: String, - color: Color, - fontSize: TextUnit, - modifier: Modifier = Modifier, - fontWeight: FontWeight = FontWeight.Normal, -) { - var truncated by remember(text) { mutableStateOf(false) } - val interaction = remember { MutableInteractionSource() } - val hovered by interaction.collectIsHoveredAsState() - - Box(modifier.hoverable(interaction)) { - SocialText( - text, - color = color, - fontSize = fontSize, - fontWeight = fontWeight, - maxLines = 1, - softWrap = true, - overflow = TextOverflow.Ellipsis, - onTextLayout = { truncated = it.hasVisualOverflow }, - ) - if (truncated && hovered) { - TooltipPopup(text) - } - } -} - -@Composable -private fun TooltipPopup(text: String) { - val gap = with(LocalDensity.current) { 6.dp.roundToPx() } - val positionProvider = remember(gap) { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) - val above = anchorBounds.top - gap - popupContentSize.height - val y = if (above >= 0) above else anchorBounds.bottom + gap - return IntOffset(x, y) - } - } - } - Popup( - popupPositionProvider = positionProvider, - properties = PopupProperties(focusable = false, clippingEnabled = false), - ) { - Box( - modifier = Modifier.widthIn(max = 240.dp) - .clip(ppShape(6.dp)) - .background(LocalTheme.current.popupBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) - .padding(horizontal = 8.dp, vertical = 5.dp), - ) { - SocialText(text, color = LocalTheme.current.textColor, fontSize = 12.sp) - } - } -} - -@Composable -private fun rememberCosmeticItems(refreshKey: Int): List = - remember(refreshKey) { - val ownedIds = CosmeticCatalog.ownedIds() - val equippedIds = CosmeticCatalog.localEquipped().equipped.values.toSet() - val selectedEmote = CosmeticCatalog.selectedEmoteId() - - val groupItems = CosmeticCatalog.cosmeticGroupViews() - .sortedWith(compareBy { it.type.ordinal }.thenBy { it.groupId }) - .map { group -> - val byLabel = LinkedHashMap() - for (variant in group.variants) { - byLabel.getOrPut(variant.variantName) { - CosmeticVariantUi(variant.id, variant.variantName) - } - } - val equippedVariantId = group.variants.firstOrNull { it.id in equippedIds }?.id - CosmeticUiItem( - groupId = group.groupId, - type = group.type, - name = group.name, - collection = "${group.type.displayName} Collection", - owned = group.variants.any { it.id in ownedIds }, - equipped = equippedVariantId != null, - variants = byLabel.values.toList(), - equippedVariantId = equippedVariantId, - ) - } - - val emoteItems = CosmeticCatalog.allEmoteDefinitions() - .sortedBy { it.id } - .map { emote -> - CosmeticUiItem( - groupId = emote.id, - type = CosmeticType.Emote, - name = emote.name, - collection = "${CosmeticType.Emote.displayName} Collection", - owned = emote.id in ownedIds, - equipped = selectedEmote == emote.id, - variants = listOf(CosmeticVariantUi(emote.id, emote.name)), - equippedVariantId = if (selectedEmote == emote.id) emote.id else null, - ) - } - - groupItems + emoteItems - } - -private fun selectedVariantId(item: CosmeticUiItem, picks: Map): Int? = - picks[item.groupId] ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id - -private fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> Unit) { - if (!item.owned) { - onComplete("${item.name} is not in your locker.") - return - } - - PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.equipEmote(variantId) - } else { - CosmeticService.equip(variantId) - } - ClientPlatform.runOnMain { - onComplete( - result.fold( - onSuccess = { "Equipped ${item.name}." }, - onFailure = { "Failed to equip ${item.name}: ${it.message}" }, - ), - ) - } - } -} - -private fun unequip(item: CosmeticUiItem, onComplete: (String) -> Unit) { - PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.clearEmote() - } else { - val slot = CosmeticCatalog.localEquipped().equipped.entries - .firstOrNull { it.value == item.equippedVariantId }?.key - if (slot == null) { - Result.failure(IllegalStateException("${item.name} is not equipped")) - } else { - CosmeticService.clearSlot(slot) - } - } - ClientPlatform.runOnMain { - onComplete( - result.fold( - onSuccess = { "Unequipped ${item.name}." }, - onFailure = { "Failed to unequip ${item.name}: ${it.message}" }, - ), - ) - } - } -} - -private fun cardBrush(): Brush = - Brush.verticalGradient( - listOf( - Color(0x59232D32), - Color(0xB3232D32), - ), - ) - -private fun money(amount: Float, currency: String? = null): String { - val formatted = String.format("%.2f", amount) - return if (currency == null || currency.equals("usd", ignoreCase = true)) "$$formatted" else "$formatted ${currency.uppercase()}" -} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt new file mode 100644 index 00000000..f7e46daa --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt @@ -0,0 +1,421 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +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.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.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.awt.Color as AwtColor +import kotlin.math.ceil +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun AuraSwatchRow( + selectedColor: Int?, + customOpen: Boolean, + onToggleCustom: () -> Unit, + onCommit: (Int?) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + AuraDot( + color = Color(DEFAULT_AURA_ARGB), + isSelected = selectedColor == null, + onClick = { onCommit(null) }, + ) + for (color in AURA_PRESETS) { + AuraDot( + color = Color(color), + isSelected = selectedColor == color, + onClick = { onCommit(color) }, + ) + } + val isCustom = selectedColor != null && selectedColor !in AURA_PRESETS + AuraDot( + color = if (isCustom) Color(selectedColor!!) else LocalTheme.current.componentBackground, + isSelected = isCustom || customOpen, + onClick = onToggleCustom, + ) { + if (!isCustom) { + SocialText("+", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) + } + } + } +} + +@Composable +private fun AuraDot( + color: Color, + isSelected: Boolean, + onClick: () -> Unit, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = Modifier.size(18.dp) + .clip(ppShape(6.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Checkerboard(Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(color)) + Box( + Modifier.fillMaxSize().border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) Accent else LocalTheme.current.borderColor, + shape = ppShape(6.dp), + ), + ) + content() + } +} + +private val AURA_COLORS: List = listOf( + 0xFFFF4444.toInt(), + 0xFFFF9E3D.toInt(), + 0xFFF4D03F.toInt(), + 0xFF2ECC71.toInt(), + 0xFF1ABC9C.toInt(), + 0xFF3DA5FF.toInt(), + 0xFF6C5CE7.toInt(), + 0xFFB05CFF.toInt(), + 0xFFFF6FD8.toInt(), + 0xFFFFFFFF.toInt(), + 0xFF3A3F43.toInt(), + 0xFF9BA2A6.toInt(), +) + +private const val DEFAULT_AURA_ARGB: Int = -1 + +private val AURA_PRESETS: List = AURA_COLORS.filter { it != DEFAULT_AURA_ARGB } + +/** Converts an ARGB int to a HSB float array (hue in degrees, saturation and brightness as a float between 0 and 1). */ +private fun argbToHsb(argb: Int): FloatArray = + AwtColor.RGBtoHSB((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF, null) + .also { it[0] *= 360f } + +private fun hsbToColor(hue: Float, saturation: Float, brightness: Float): Color = + Color.hsv(hue, saturation, brightness) + +private fun argbHex(argb: Int): String = + "%02X%02X%02X".format((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF) + +private fun hexToArgb(hex: String, alpha: Float): Int? { + val h = hex.removePrefix("#").trim() + if (h.length != 6) return null + return try { + ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or Integer.parseInt(h, 16) + } catch (_: Exception) { + null + } +} + +private fun Color.toArgb(alpha: Float): Int = + ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or + ((red.coerceIn(0f, 1f) * 255f).roundToInt() shl 16) or + ((green.coerceIn(0f, 1f) * 255f).roundToInt() shl 8) or + (blue.coerceIn(0f, 1f) * 255f).roundToInt() + +private class AuraPickerState(argb: Int) { + var hue by mutableFloatStateOf(0f) + var saturation by mutableFloatStateOf(0f) + var brightness by mutableFloatStateOf(0f) + var alpha by mutableFloatStateOf(1f) + + var lastEmitted: Int? = null + + init { + applyFrom(argb) + } + + fun applyFrom(argb: Int) { + val hsb = argbToHsb(argb) + hue = hsb[0] + saturation = hsb[1] + brightness = hsb[2] + alpha = ((argb ushr 24) and 0xFF) / 255f + } + + fun currentArgb(): Int = hsbToColor(hue, saturation, brightness).toArgb(alpha) +} + +@Composable +internal fun Checkerboard(modifier: Modifier, cell: Dp = 4.dp) { + Box( + modifier = modifier.drawBehind { + val step = cell.toPx() + val cols = ceil(size.width / step).toInt() + val rows = ceil(size.height / step).toInt() + for (x in 0..cols) { + for (y in 0..rows) { + drawRect( + color = if ((x + y) % 2 == 0) Color(0xFF5F6568) else Color(0xFF3D4245), + topLeft = Offset(x * step, y * step), + size = Size(step, step), + ) + } + } + }, + ) +} + +@Composable +internal fun AuraCustomPopover( + selectedColor: Int?, + onPreview: (Int) -> Unit, + onCommit: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + val state = remember { AuraPickerState(selectedColor ?: 0xFFFFFFFF.toInt()) } + var hexText by remember { mutableStateOf(argbHex(state.currentArgb())) } + LaunchedEffect(selectedColor) { + if (selectedColor != null && selectedColor != state.lastEmitted) { + state.applyFrom(selectedColor) + hexText = argbHex(selectedColor) + } + } + + fun emitPreview() { + val argb = state.currentArgb() + state.lastEmitted = argb + hexText = argbHex(argb) + onPreview(argb) + } + + fun emitCommit() { + val argb = state.currentArgb() + state.lastEmitted = argb + onCommit(argb) + } + + val hue = state.hue + val saturation = state.saturation + val brightness = state.brightness + val alpha = state.alpha + val current = hsbToColor(hue, saturation, brightness) + + Column( + modifier = modifier.width(190.dp) + .clip(ppShape(16.dp)) + .background(LocalTheme.current.popupBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Aura Color", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + + var paneSize by remember { mutableStateOf(Size.Zero) } + Box( + modifier = Modifier.fillMaxWidth().height(120.dp) + .clip(ppShape(6.dp)) + .onSizeChanged { paneSize = Size(it.width.toFloat(), it.height.toFloat()) } + .drawWithCache { + val hueColor = hsbToColor(hue, 1f, 1f) + val horizontal = Brush.horizontalGradient(listOf(Color.White, hueColor)) + val vertical = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + onDrawBehind { + drawRect(horizontal) + drawRect(vertical) + } + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float, y: Float) { + state.saturation = (x / paneSize.width).coerceIn(0f, 1f) + state.brightness = 1f - (y / paneSize.height).coerceIn(0f, 1f) + emitPreview() + } + update(down.position.x, down.position.y) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x, ch.position.y) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + val selX = (saturation * paneSize.width).coerceIn(0f, paneSize.width) + val selY = ((1f - brightness) * paneSize.height).coerceIn(0f, paneSize.height) + Box( + modifier = Modifier + .offset { IntOffset((selX - 7.dp.toPx()).roundToInt(), (selY - 7.dp.toPx()).roundToInt()) } + .size(14.dp) + .clip(ppShape(6.dp)) + .background(current) + .border(2.dp, Color.White, ppShape(6.dp)), + ) + } + + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .clip(ppShape(6.dp)) + .drawWithCache { + val gradient = Brush.horizontalGradient( + listOf(Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red), + ) + onDrawBehind { drawRect(gradient) } + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float) { + state.hue = ((x / size.width) * 360f).coerceIn(0f, 360f) + emitPreview() + } + update(down.position.x) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + var barWidth by remember { mutableStateOf(0f) } + Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { barWidth = it.width.toFloat() }) + Box( + modifier = Modifier.align(Alignment.CenterStart) + .offset { IntOffset(((hue / 360f) * barWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } + .size(10.dp) + .clip(ppShape(5.dp)) + .background(Color.White) + .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), + ) + } + + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .clip(ppShape(6.dp)) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float) { + state.alpha = (x / size.width).coerceIn(0f, 1f) + emitPreview() + } + update(down.position.x) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + Checkerboard(Modifier.fillMaxWidth().height(14.dp)) + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .drawWithCache { + val gradient = Brush.horizontalGradient(listOf(current.copy(alpha = 0f), current)) + onDrawBehind { drawRect(gradient) } + }, + ) + var alphaBarWidth by remember { mutableStateOf(0f) } + Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { alphaBarWidth = it.width.toFloat() }) + Box( + modifier = Modifier.align(Alignment.CenterStart) + .offset { IntOffset((alpha * alphaBarWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } + .size(10.dp) + .clip(ppShape(5.dp)) + .background(Color.White) + .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.size(28.dp).clip(ppShape(5.dp))) { + Checkerboard(Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(current.copy(alpha = alpha))) + Box(Modifier.fillMaxSize().border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp))) + } + BasicTextField( + value = hexText, + onValueChange = { input -> + val filtered = input.filter { it.isLetterOrDigit() }.take(6) + hexText = filtered + hexToArgb(filtered, state.alpha)?.let { argb -> + state.applyFrom(argb) + state.lastEmitted = argb + onCommit(argb) + } + }, + singleLine = true, + textStyle = TextStyle( + color = LocalTheme.current.textColor, + fontSize = 12.sp, + fontFamily = LocalTheme.current.typography.family, + ), + cursorBrush = SolidColor(LocalTheme.current.textColor), + modifier = Modifier.weight(1f) + .clip(ppShape(5.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .trackTextInputFocus(), + decorationBox = { inner -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SocialText("#", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + inner() + } + }, + ) + } + + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt new file mode 100644 index 00000000..86dd4223 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt @@ -0,0 +1,124 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import kotlinx.coroutines.launch +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView +import org.polyfrost.polyplus.client.cosmetics.CosmeticService +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.ClientPlatform + +internal fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { + if (MojangCapeService.busy) return + onStatus(if (cape.active) "Removing ${cape.name}..." else "Equipping ${cape.name}...") + PolyPlusClient.SCOPE.launch { + val result = if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) + ClientPlatform.runOnMain { + onStatus( + result.fold( + onSuccess = { if (cape.active) "Removed ${cape.name}." else "Equipped ${cape.name}." }, + onFailure = MojangCapeService::failureMessage, + ), + ) + } + } +} + +@Composable +internal fun rememberCosmeticItems(refreshKey: Int): List = + remember(refreshKey) { + val ownedIds = CosmeticCatalog.ownedIds() + val equippedIds = CosmeticCatalog.localEquipped().equipped.values.toSet() + val selectedEmote = CosmeticCatalog.selectedEmoteId() + + val groupItems = CosmeticCatalog.cosmeticGroupViews() + .sortedWith(compareBy { it.type.ordinal }.thenBy { it.groupId }) + .map { group -> + val byLabel = LinkedHashMap() + for (variant in group.variants) { + byLabel.getOrPut(variant.variantName) { + CosmeticVariantUi(variant.id, variant.variantName) + } + } + val equippedVariantId = group.variants.firstOrNull { it.id in equippedIds }?.id + CosmeticUiItem( + groupId = group.groupId, + type = group.type, + name = group.name, + collection = "${group.type.displayName} Collection", + owned = group.variants.any { it.id in ownedIds }, + equipped = equippedVariantId != null, + variants = byLabel.values.toList(), + equippedVariantId = equippedVariantId, + ) + } + + val emoteItems = CosmeticCatalog.allEmoteDefinitions() + .sortedBy { it.id } + .map { emote -> + CosmeticUiItem( + groupId = emote.id, + type = CosmeticType.Emote, + name = emote.name, + collection = "${CosmeticType.Emote.displayName} Collection", + owned = emote.id in ownedIds, + equipped = selectedEmote == emote.id, + variants = listOf(CosmeticVariantUi(emote.id, emote.name)), + equippedVariantId = if (selectedEmote == emote.id) emote.id else null, + ) + } + + groupItems + emoteItems + } + +internal fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> Unit) { + if (!item.owned) { + onComplete("${item.name} is not in your locker.") + return + } + + PolyPlusClient.SCOPE.launch { + val result = if (item.type == CosmeticType.Emote) { + CosmeticService.equipEmote(variantId) + } else { + CosmeticService.equip(variantId) + } + ClientPlatform.runOnMain { + onComplete( + result.fold( + onSuccess = { "Equipped ${item.name}." }, + onFailure = { "Failed to equip ${item.name}: ${it.message}" }, + ), + ) + } + } +} + +internal fun unequip(item: CosmeticUiItem, onComplete: (String) -> Unit) { + PolyPlusClient.SCOPE.launch { + val result = if (item.type == CosmeticType.Emote) { + CosmeticService.clearEmote() + } else { + val slot = CosmeticCatalog.localEquipped().equipped.entries + .firstOrNull { it.value == item.equippedVariantId }?.key + if (slot == null) { + Result.failure(IllegalStateException("${item.name} is not equipped")) + } else { + CosmeticService.clearSlot(slot) + } + } + ClientPlatform.runOnMain { + onComplete( + result.fold( + onSuccess = { "Unequipped ${item.name}." }, + onFailure = { "Failed to unequip ${item.name}: ${it.message}" }, + ), + ) + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt new file mode 100644 index 00000000..dc9be58a --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt @@ -0,0 +1,92 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig +import org.polyfrost.polyplus.client.cosmetics.CapeResolver +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.SocialToggle +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun ForceCapesControl() { + var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } + val modName = CapeResolver.localOverrideModName + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SocialToggle("Force PolyPlus capes", checked = forced) { + forced = !forced + PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced + PolyPlusCosmeticsConfig.save() + } + val note = when { + modName == null -> "When another mod sets its own cape, that cape is shown unless this is on." + forced -> "$modName sets its own cape, but the capes picked here are shown instead." + else -> "Your cape is set by $modName. Turn this on to show the capes picked here." + } + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } +} + +@Composable +internal fun CapeSectionHeader( + title: String, + note: String, + action: String? = null, + onAction: () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + if (action != null) { + Box( + modifier = Modifier.height(28.dp) + .clip(ppShape(8.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) + .clickable(onClick = onAction) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + SocialText(action, color = LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +internal fun CapeSectionNote(text: String) { + Box( + modifier = Modifier.fillMaxWidth().height(57.dp) + .clip(ppShape(16.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt new file mode 100644 index 00000000..c317985d --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt @@ -0,0 +1,152 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.ReadOnlyComposable +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.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun SmallButton( + label: String, + iconPath: String, + primary: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.height(33.dp) + .clip(ppShape(8.dp)) + .background(if (primary) Accent else LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(iconPath, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(8.dp)) + SocialText(label, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +internal fun CenteredNote(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, textAlign = TextAlign.Center) + } +} + +@Composable +internal fun CardLabel( + text: String, + color: Color, + fontSize: TextUnit, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Normal, +) { + var truncated by remember(text) { mutableStateOf(false) } + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + + Box(modifier.hoverable(interaction)) { + SocialText( + text, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + maxLines = 1, + softWrap = true, + overflow = TextOverflow.Ellipsis, + onTextLayout = { truncated = it.hasVisualOverflow }, + ) + if (truncated && hovered) { + TooltipPopup(text) + } + } +} + +@Composable +internal fun TooltipPopup(text: String) { + val gap = with(LocalDensity.current) { 6.dp.roundToPx() } + val positionProvider = remember(gap) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val above = anchorBounds.top - gap - popupContentSize.height + val y = if (above >= 0) above else anchorBounds.bottom + gap + return IntOffset(x, y) + } + } + } + Popup( + popupPositionProvider = positionProvider, + properties = PopupProperties(focusable = false, clippingEnabled = false), + ) { + Box( + modifier = Modifier.widthIn(max = 240.dp) + .clip(ppShape(6.dp)) + .background(LocalTheme.current.popupBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 5.dp), + ) { + SocialText(text, color = LocalTheme.current.textColor, fontSize = 12.sp) + } + } +} + +@Composable +@ReadOnlyComposable +internal fun cardBrush(): Brush = SolidColor(LocalTheme.current.modCardBackground) + +@Composable +internal fun Modifier.wardrobePanel(): Modifier = + clip(ppShape(16.dp)) + .background(LocalTheme.current.modCardBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt new file mode 100644 index 00000000..296da421 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -0,0 +1,367 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.ppShapeOf +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun WardrobeGrid( + entries: List, + state: LazyGridState, + ownedIds: Set, + cartKeys: Set, + selection: WardrobeSelection?, + storeVariantPicks: Map, + mojangBusy: Boolean, + modifier: Modifier, + coverFor: (CosmeticUiItem) -> Int?, + onOwned: (CosmeticUiItem) -> Unit, + onShop: (CosmeticStoreInfo) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onMojang: (MojangCape) -> Unit, + onBundle: (BundleInfo) -> Unit, + onOpenStore: () -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = state, + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + entries, + key = { it.key }, + span = { if (it.fullSpan) GridItemSpan(maxLineSpan) else GridItemSpan(1) }, + ) { entry -> + when (entry) { + is WardrobeEntry.Divider -> WardrobeDivider(entry.label) + is WardrobeEntry.Group -> CapeSectionHeader( + title = entry.title, + note = entry.note, + action = entry.action, + onAction = onOpenStore, + ) + is WardrobeEntry.Note -> CapeSectionNote(entry.text) + WardrobeEntry.ForceCapes -> ForceCapesControl() + is WardrobeEntry.Owned -> { + val item = entry.item + WardrobeCard( + name = item.name, + detail = { CardDetailText(item.collection) }, + action = equipAction(item.equipped), + onAction = { onOwned(item) }, + equipped = item.equipped, + selected = selection == WardrobeSelection.Owned(item.groupId, item.type), + onClick = { onOwned(item) }, + ) { modifier -> + val (source, loadTick) = rememberCosmeticPreviewSource(item) + CosmeticThumbnail( + source, + item.type, + "card-${item.groupId}-$loadTick", + modifier, + checker = false, + coverAssetId = coverFor(item), + ) + } + } + is WardrobeEntry.Shop -> { + val info = entry.info + val variant = selectedStoreVariant(info, storeVariantPicks, ownedIds) + val owned = info.fullyOwned(ownedIds) + val inCart = cosmeticCartKey(variant.id) in cartKeys + val variantCount = info.uiVariants().size + WardrobeCard( + name = info.name, + detail = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PriceLabel(info) + if (variantCount > 1) { + SocialText("$variantCount variants", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) + } + } + }, + action = when { + owned -> CardAction("Owned", "tick", enabled = false) + inCart -> CardAction("In cart", "tick", enabled = false) + else -> CardAction("Add to cart", "plus") + }, + onAction = { onAddToCart(info, variant) }, + equipped = false, + selected = selection == WardrobeSelection.Shop(info.id), + badge = when { + owned -> null + info.discounted -> CardBadge("${info.discountRate}% OFF", CardBadgeStyle.Sale) + isNewItem(info.createdAt) -> CardBadge("NEW", CardBadgeStyle.New) + else -> null + }, + onClick = { onShop(info) }, + ) { modifier -> + val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) + CosmeticThumbnail( + source, + info.type, + "shop-${variant.id}-$loadTick", + modifier, + checker = false, + coverAssetId = info.coverAssetId, + ) + } + } + is WardrobeEntry.Mojang -> { + val cape = entry.cape + WardrobeCard( + name = cape.name, + detail = { CardDetailText(MOJANG_CAPE_COLLECTION) }, + action = equipAction(cape.active), + onAction = { onMojang(cape) }, + equipped = cape.active, + selected = selection == WardrobeSelection.Mojang(cape.id), + enabled = !mojangBusy, + onClick = { onMojang(cape) }, + ) { modifier -> + val textureVersion = MojangCapeService.textureVersion + val texture = remember(cape.textureHash, textureVersion) { MojangCapeService.texture(cape) } + val source = remember(texture) { + texture?.let { PlayerPreviewSource.Override(CosmeticEquipment(), mojangCape = it) } + } + CosmeticThumbnail( + source, + CosmeticType.Cape, + "mojang-${cape.textureHash}-${texture != null}", + modifier, + checker = false, + ) + } + } + is WardrobeEntry.Bundle -> { + val bundle = entry.bundle + WardrobeCard( + name = bundle.name, + detail = { + Row(verticalAlignment = Alignment.CenterVertically) { + PriceLabel(bundle.basePrice, bundle.finalPrice, bundle.discounted) + } + }, + action = CardAction("View bundle", "right-arrow"), + onAction = { onBundle(bundle) }, + equipped = false, + selected = selection == WardrobeSelection.Bundle(bundle.id), + badge = if (bundle.discounted) { + CardBadge("SAVE ${bundle.discountRate}%", CardBadgeStyle.Sale) + } else { + CardBadge("BUNDLE", CardBadgeStyle.Accent) + }, + onClick = { onBundle(bundle) }, + ) { modifier -> + CoverThumbnail(bundle.assetId, modifier) + } + } + } + } + } +} + +@Composable +private fun WardrobeDivider(label: String) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.weight(1f).height(1.dp).background(theme.borderColor)) + SocialText(label.uppercase(), color = theme.textColorSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Box(Modifier.weight(1f).height(1.dp).background(theme.borderColor)) + } +} + +private data class CardAction(val label: String, val icon: String, val enabled: Boolean = true) + +private enum class CardBadgeStyle { New, Sale, Accent } + +private data class CardBadge(val label: String, val style: CardBadgeStyle) + +private fun equipAction(equipped: Boolean): CardAction = + if (equipped) CardAction("Equipped", "tick") else CardAction("Equip", "profiles") + +private val CARD_SUCCESS = Color(0xFF239A60) +private val CARD_NEUTRAL_CHIP = Color(0xFFD9D9D9) +private val CARD_FRAME = Color(0xFF232D32) +private val CARD_HAIRLINE = Color(0x1AFFFFFF) +private val CARD_STRIP = Color(0xB3232D32) +private val CARD_HIGHLIGHT = Color(0x0DFFFFFF) + +@Composable +private fun CardDetailText(text: String) { + CardLabel(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.fillMaxWidth()) +} + +@Composable +private fun WardrobeCard( + name: String, + detail: @Composable () -> Unit, + action: CardAction, + onAction: () -> Unit, + equipped: Boolean, + selected: Boolean, + onClick: () -> Unit, + badge: CardBadge? = null, + enabled: Boolean = true, + thumbnail: @Composable (Modifier) -> Unit, +) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val outline = when { + equipped -> Accent + selected || (hovered && enabled) -> theme.textColorSecondary + else -> theme.borderColor + } + Box( + modifier = Modifier.fillMaxWidth() + .alpha(if (enabled) 1f else 0.6f) + .clip(ppShape(12.dp)) + .background(theme.modCardBackground) + .drawBehind { + drawRect( + Brush.radialGradient( + colors = listOf(Color.Transparent, CARD_HIGHLIGHT), + center = Offset(size.width / 2f, size.height * 0.8f), + radius = size.maxDimension * 0.75f, + ), + ) + } + .border(1.dp, outline, ppShape(12.dp)) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick), + ) { + Column(Modifier.fillMaxWidth()) { + BoxWithConstraints(Modifier.fillMaxWidth().padding(start = 18.dp, end = 18.dp, top = 18.dp)) { + Box( + modifier = Modifier.fillMaxWidth().height(maxWidth) + .clip(ppShape(5.dp)) + .background(CARD_FRAME) + .border(1.dp, CARD_HAIRLINE, ppShape(5.dp)), + ) { + thumbnail(Modifier.fillMaxSize()) + } + } + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + CardLabel(name, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.fillMaxWidth()) + detail() + } + Spacer(Modifier.height(12.dp)) + CardActionStrip(action, active = equipped, enabled = enabled && action.enabled, onAction = onAction) + } + if (badge != null) { + CardBadgeTab(badge, Modifier.align(Alignment.TopCenter)) + } + } +} + +@Composable +private fun CardActionStrip(action: CardAction, active: Boolean, enabled: Boolean, onAction: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val color = if (active) theme.accentTextColor else theme.textColor + Box(Modifier.fillMaxWidth().height(36.dp)) { + Row( + modifier = Modifier.fillMaxSize() + .background(if (active) Accent else CARD_STRIP) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onAction), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(action.icon, color = color, modifier = Modifier.size(16.dp)) + SocialText(action.label, color = color, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + if (!active) { + Box( + Modifier.fillMaxWidth().height(1.dp) + .background(if (hovered && enabled) theme.textColorSecondary else CARD_HAIRLINE), + ) + } + } +} + +@Composable +private fun CardBadgeTab(badge: CardBadge, modifier: Modifier) { + val theme = LocalTheme.current + Box( + modifier = modifier.widthIn(min = 81.dp).height(21.dp) + .clip(ppShapeOf(bottomStart = 7.dp, bottomEnd = 7.dp)) + .background( + when (badge.style) { + CardBadgeStyle.New -> CARD_SUCCESS + CardBadgeStyle.Sale -> CARD_NEUTRAL_CHIP + CardBadgeStyle.Accent -> Accent + }, + ) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + SocialText( + badge.label, + color = if (badge.style == CardBadgeStyle.New) theme.textColor else theme.accentTextColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt new file mode 100644 index 00000000..e924893e --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt @@ -0,0 +1,83 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun WardrobeHeader( + title: String, + sort: WardrobeSort, + cartSize: Int, + showingHistory: Boolean, + storeEnabled: Boolean, + onCycleSort: () -> Unit, + onToggleHistory: () -> Unit, + onRefresh: () -> Unit, + onCart: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, maxLines = 1) + Spacer(Modifier.weight(1f)) + if (storeEnabled && !showingHistory) { + SmallButton(sort.label, iconPath = "align", primary = false, onClick = onCycleSort) + } + if (storeEnabled) { + HeaderIconButton( + iconPath = if (showingHistory) "left-arrow" else "clipboard", + tooltip = if (showingHistory) "Back to the wardrobe" else "Purchase history", + onClick = onToggleHistory, + ) + } + HeaderIconButton(iconPath = "refresh", tooltip = "Refresh", onClick = onRefresh) + if (storeEnabled) { + SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) + } + } +} + +@Composable +private fun HeaderIconButton(iconPath: String, tooltip: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box( + modifier = Modifier.size(33.dp) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, if (hovered) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(iconPath, color = theme.textColor, modifier = Modifier.size(14.dp)) + if (hovered) TooltipPopup(tooltip) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt new file mode 100644 index 00000000..af86d346 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt @@ -0,0 +1,99 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.BillingService +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.network.http.responses.TransactionInfo +import org.polyfrost.polyplus.client.network.http.responses.TransactionStatus + +@Composable +internal fun HistoryScreen(ownedIds: Set, reloadKey: Int) { + var transactions by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(null) } + + // keyed on ownership rather than the catalog poll so transactions only refetch when a purchase lands + LaunchedEffect(ownedIds, reloadKey) { + loading = true + loadError = null + BillingService.fetchTransactions() + .onSuccess { transactions = it } + .onFailure { loadError = "Couldn't load transactions: ${it.message}" } + loading = false + } + + Box( + modifier = Modifier.fillMaxSize().clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(18.dp), + ) { + when { + loading && transactions.isEmpty() -> CenteredNote("Loading transactions...") + loadError != null && transactions.isEmpty() -> CenteredNote(loadError!!) + transactions.isEmpty() -> CenteredNote("No transactions yet.") + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { + items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } + } + } + } +} + +@Composable +private fun TransactionRow(tx: TransactionInfo) { + Row( + modifier = Modifier.fillMaxWidth() + .clip(ppShape(8.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) + .padding(horizontal = 13.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Column(Modifier.weight(1f)) { + SocialText("Order #${tx.id}", color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + SocialText(tx.provider.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + tx.amount?.let { SocialText(money(it, tx.currency), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) } + SocialText( + tx.status.displayName, + color = statusColor(tx.status), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +private fun statusColor(status: TransactionStatus): Color = when (status) { + TransactionStatus.Completed -> Color(0xFF239A60) + TransactionStatus.Pending -> Color(0xFFE0A030) + TransactionStatus.Failed -> Color(0xFFFF4444) + TransactionStatus.Refunded, TransactionStatus.PartiallyRefunded, TransactionStatus.Chargeback -> Color(0xFF8A9296) + TransactionStatus.Unknown -> Color(0xFF8A9296) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt new file mode 100644 index 00000000..4d20f19e --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt @@ -0,0 +1,122 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticLoadProgress +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun CosmeticsLoadingScreen( + state: CosmeticLoadProgress.Snapshot, + onRetry: () -> Unit, +) { + val failed = state.failure != null + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + ) { + SocialText( + if (failed) "Couldn't load cosmetics" else "Loading cosmetics", + color = LocalTheme.current.textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + SocialText( + if (failed) { + "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." + } else { + state.label + }, + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + modifier = Modifier.width(360.dp), + textAlign = TextAlign.Center, + ) + if (failed) { + Spacer(Modifier.height(2.dp)) + SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) + } else { + ProgressBar(state.fraction, Modifier.width(360.dp)) + } + } +} + +private const val SWEEP_WIDTH_FRACTION = 0.35f + +@Composable +private fun ProgressBar(fraction: Float?, modifier: Modifier) { + val track = LocalTheme.current.chipBackground + val shape = ppShape(4.dp) + Box( + modifier + .height(8.dp) + .clip(shape) + .background(track) + .border(1.dp, LocalTheme.current.borderColor, shape), + ) { + if (fraction != null) { + val animated by animateFloatAsState(fraction.coerceIn(0f, 1f), animationSpec = tween(220)) + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(animated) + .clip(shape) + .background(Accent), + ) + } else { + val transition = rememberInfiniteTransition(label = "cosmeticsLoadingSweep") + val head by transition.animateFloat( + initialValue = -SWEEP_WIDTH_FRACTION, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1_100, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "cosmeticsLoadingSweepHead", + ) + BoxWithConstraints(Modifier.fillMaxSize()) { + val width = maxWidth + Box( + Modifier + .offset(x = width * head) + .fillMaxHeight() + .width(width * SWEEP_WIDTH_FRACTION) + .clip(shape) + .background(Accent), + ) + } + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt new file mode 100644 index 00000000..d5bc6292 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -0,0 +1,170 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.key +import org.polyfrost.polyplus.client.cosmetics.BillingService +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +internal data class CartEntry( + val key: String, + val name: String, + val description: String?, + val basePrice: Float?, + val finalPrice: Float?, + val discountRate: Int?, + val storeProductId: String?, + val coverAssetId: Int? = null, +) { + val free: Boolean get() = (finalPrice ?: 0f) <= 0f + + val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free +} + +internal fun CosmeticStoreInfo.toCartEntry(variant: CosmeticVariantUi, storeProductId: String?): CartEntry = + CartEntry( + cosmeticCartKey(variant.id), + cartName(variant), + description, + basePrice, + finalPrice, + discountRate, + storeProductId, + coverAssetId, + ) + +internal fun cosmeticCartKey(variantId: Int): String = "cosmetic-$variantId" + +internal fun CosmeticStoreInfo.cartName(variant: CosmeticVariantUi): String = + if (variantList.size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name + +internal data class CosmeticVariantUi(val id: Int, val name: String) + +internal fun CosmeticStoreInfo.uiVariants(): List { + val byLabel = LinkedHashMap() + for (variant in variantList.sortedBy { it.variantOrder }) { + val label = variant.variantName ?: name + byLabel.getOrPut(label) { CosmeticVariantUi(variant.id, label) } + } + return byLabel.values.toList() +} + +internal fun CosmeticStoreInfo.fullyOwned(ownedIds: Set): Boolean = + uiVariants().all { it.id in ownedIds } + +internal fun selectedStoreVariant( + info: CosmeticStoreInfo, + picks: Map, + ownedIds: Set, +): CosmeticVariantUi { + val variants = info.uiVariants() + val picked = picks[info.id] + return variants.firstOrNull { it.id == picked } + ?: variants.firstOrNull { it.id !in ownedIds } + ?: variants.first() +} + +internal fun checkoutFailureMessage(error: Throwable): String = + if (error is BillingService.AlreadyOwnedException) { + error.message ?: "You already own this item." + } else { + "Checkout failed: ${error.message}" + } + +internal data class CosmeticUiItem( + val groupId: Int, + val type: CosmeticType, + val name: String, + val collection: String, + val owned: Boolean, + val equipped: Boolean, + val variants: List, + val equippedVariantId: Int?, +) { + val id: Int get() = groupId + val hasVariants: Boolean get() = variants.size > 1 +} + +internal const val MOJANG_CAPE_COLLECTION = "Minecraft cape" + +internal fun selectedVariantId(item: CosmeticUiItem, picks: Map): Int? = + picks[item.groupId] ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id + +internal enum class WardrobeSection(val label: String, val icon: String) { + Featured("Featured", "star-filled"), + Outfits("Outfits", "profiles"), + Skins("Skins", "paintbrush"), + Emotes("Emotes", "lightning-01"), + Cosmetics("Cosmetics", "assets/polyplus/ico/stars.svg"), +} + +internal enum class WardrobeSort(val label: String) { + Newest("Newest"), + PriceLow("Lowest price"), + PriceHigh("Highest price"), + Name("Name"), +} + +internal sealed interface WardrobeEntry { + val key: String + val fullSpan: Boolean get() = false + + data class Divider(val label: String, val anchor: Boolean) : WardrobeEntry { + override val key: String get() = "divider-$label" + override val fullSpan: Boolean get() = true + } + + data class Group(val id: String, val title: String, val note: String, val action: String? = null) : WardrobeEntry { + override val key: String get() = "group-$id" + override val fullSpan: Boolean get() = true + } + + data class Note(val id: String, val text: String) : WardrobeEntry { + override val key: String get() = "note-$id" + override val fullSpan: Boolean get() = true + } + + data object ForceCapes : WardrobeEntry { + override val key: String get() = "force-capes" + override val fullSpan: Boolean get() = true + } + + data class Owned(val item: CosmeticUiItem) : WardrobeEntry { + override val key: String get() = "owned-${item.type.serializedName}-${item.groupId}" + } + + data class Shop(val info: CosmeticStoreInfo, val group: String) : WardrobeEntry { + override val key: String get() = "shop-$group-${info.id}" + } + + data class Mojang(val cape: MojangCape) : WardrobeEntry { + override val key: String get() = "mojang-${cape.id}" + } + + data class Bundle(val bundle: BundleInfo) : WardrobeEntry { + override val key: String get() = "bundle-${bundle.id}" + } +} + +internal sealed interface WardrobeSelection { + data class Owned(val groupId: Int, val type: CosmeticType) : WardrobeSelection + data class Shop(val infoId: Int) : WardrobeSelection + data class Mojang(val capeId: String) : WardrobeSelection + data class Bundle(val bundleId: Int) : WardrobeSelection +} + +internal val WARDROBE_COSMETIC_TYPES: List = + CosmeticType.entries.filter { it != CosmeticType.Unknown && it != CosmeticType.Emote } + +internal fun CosmeticStoreInfo.anyOwned(ownedIds: Set): Boolean = variantList.any { it.id in ownedIds } + +internal fun List.sortedFor(sort: WardrobeSort): List = when (sort) { + WardrobeSort.Newest -> sortedByDescending { it.createdAt } + WardrobeSort.PriceLow -> sortedBy { it.finalPrice ?: 0f } + WardrobeSort.PriceHigh -> sortedByDescending { it.finalPrice ?: 0f } + WardrobeSort.Name -> sortedBy { it.name.lowercase() } +} + +internal val BACK_VIEW_TYPES = setOf(CosmeticType.Cape, CosmeticType.Wings, CosmeticType.Backpack) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt new file mode 100644 index 00000000..1e0ab502 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt @@ -0,0 +1,297 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.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.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.launch +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BodySlot + +@Composable +internal fun PreviewPanel( + selected: CosmeticUiItem?, + status: String?, + modifier: Modifier, + heading: Pair? = null, + selectedVariantId: Int? = null, + onSelectVariant: (Int) -> Unit = {}, + showBack: Boolean = false, + showAuraColor: Boolean = false, + auraColor: Int? = null, + onPreviewAuraColor: (Int) -> Unit = {}, + onSelectAuraColor: (Int?) -> Unit = {}, +) { + Box( + modifier = modifier.clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped + .containsKey(BodySlot.Hat) + key(showBack) { + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = PlayerPreviewSource.LocalLive, + autoSpin = false, + verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, + initialYaw = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG, + live = true, + ) + } + Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val (title, subtitle) = heading ?: selected?.let { it.name to it.collection } ?: (null to null) + if (title != null) { + Column { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + subtitle?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } + } + } + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + val variants = if (selected != null && selected.hasVariants) selected.variants else emptyList() + if (variants.isNotEmpty() || showAuraColor) { + PreviewPill( + variants = variants, + selectedVariantId = selectedVariantId, + onSelectVariant = onSelectVariant, + showAuraColor = showAuraColor, + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.align(Alignment.BottomCenter).padding(18.dp), + ) + } + } +} + +@Composable +private fun PreviewPill( + variants: List, + selectedVariantId: Int?, + onSelectVariant: (Int) -> Unit, + showAuraColor: Boolean, + auraColor: Int?, + onPreviewAuraColor: (Int) -> Unit, + onSelectAuraColor: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + var customOpen by remember { mutableStateOf(false) } + LaunchedEffect(showAuraColor) { if (!showAuraColor) customOpen = false } + + var pillHeight by remember { mutableIntStateOf(0) } + val popoverGapPx = with(LocalDensity.current) { 8.dp.roundToPx() } + + Box(modifier) { + Row( + modifier = Modifier + .onSizeChanged { pillHeight = it.height } + .clip(ppShape(PILL_RADIUS)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(PILL_RADIUS)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (variants.isNotEmpty()) { + VariantStrip( + variants = variants, + selectedVariantId = selectedVariantId, + onSelect = onSelectVariant, + ) + } + if (variants.isNotEmpty() && showAuraColor) { + Box(Modifier.width(1.dp).height(20.dp).background(LocalTheme.current.borderColor)) + } + if (showAuraColor) { + AuraSwatchRow( + selectedColor = auraColor, + customOpen = customOpen, + onToggleCustom = { customOpen = !customOpen }, + onCommit = onSelectAuraColor, + ) + } + } + if (customOpen && showAuraColor) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(0, pillHeight + popoverGapPx), + onDismissRequest = { customOpen = false }, + properties = PopupProperties(focusable = true), + ) { + AuraCustomPopover( + selectedColor = auraColor, + onPreview = onPreviewAuraColor, + onCommit = onSelectAuraColor, + ) + } + } + } +} + +@Composable +private fun RowScope.VariantStrip( + variants: List, + selectedVariantId: Int?, + onSelect: (Int) -> Unit, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + val wheelStep = with(LocalDensity.current) { VARIANT_WHEEL_STEP.toPx() } + val arrowStep = with(LocalDensity.current) { VARIANT_ARROW_STEP.toPx() } + val overflows = scrollState.maxValue > 0 && scrollState.maxValue != Int.MAX_VALUE + + if (overflows) { + ScrollArrow( + iconName = "left-arrow", + enabled = scrollState.value > 0, + onClick = { scope.launch { scrollState.animateScrollBy(-arrowStep) } }, + ) + } + Row( + modifier = Modifier.weight(1f, fill = false) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type != PointerEventType.Scroll) continue + val delta = event.changes.first().scrollDelta + if (delta.x == 0f && delta.y != 0f) { + scope.launch { scrollState.scrollBy(delta.y * wheelStep) } + event.changes.forEach { it.consume() } + } + } + } + } + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (variant in variants) { + val isSelected = variant.id == selectedVariantId + Box( + modifier = Modifier + .clip(ppShape(6.dp)) + .background(if (isSelected) Accent else LocalTheme.current.componentBackground) + .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) + .clickable { onSelect(variant.id) } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + SocialText( + variant.name, + color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, + fontSize = 12.sp, + ) + } + } + } + if (overflows) { + ScrollArrow( + iconName = "right-arrow", + enabled = scrollState.value < scrollState.maxValue, + onClick = { scope.launch { scrollState.animateScrollBy(arrowStep) } }, + ) + } +} + +@Composable +private fun ScrollArrow(iconName: String, enabled: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier.size(18.dp) + .clip(ppShape(6.dp)) + .background(LocalTheme.current.componentBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) + .alpha(if (enabled) 1f else 0.35f) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(iconName, color = LocalTheme.current.textColor, modifier = Modifier.size(10.dp)) + } +} + +@Composable +internal fun VariantPicker( + variants: List, + selectedVariantId: Int?, + onSelect: (Int) -> Unit, + ownedVariantIds: Set = emptySet(), +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.width(220.dp)) { + SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + for (row in variants.chunked(2)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (variant in row) { + val isSelected = variant.id == selectedVariantId + Box( + modifier = Modifier + .alpha(if (variant.id in ownedVariantIds && !isSelected) 0.55f else 1f) + .clip(ppShape(6.dp)) + .background(if (isSelected) Accent else LocalTheme.current.chipBackground) + .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) + .clickable { onSelect(variant.id) } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + SocialText( + variant.name, + color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, + fontSize = 12.sp, + ) + } + } + } + } + } +} + +private val PILL_RADIUS = 18.dp + +private val VARIANT_WHEEL_STEP = 48.dp + +private val VARIANT_ARROW_STEP = 96.dp diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt new file mode 100644 index 00000000..a163a56f --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt @@ -0,0 +1,105 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun WardrobeRail( + sections: List, + selected: WardrobeSection, + anchors: List>, + modifier: Modifier, + onSelect: (WardrobeSection) -> Unit, + onAnchor: (Int) -> Unit, +) { + Column( + modifier = modifier.wardrobePanel() + .verticalScroll(rememberScrollState()) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (section in sections) { + RailEntry(section, active = section == selected, onClick = { onSelect(section) }) + if (section == selected && anchors.size > 1) { + Column( + modifier = Modifier.padding(start = 34.dp, top = 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + for ((label, index) in anchors) { + RailAnchor(label, onClick = { onAnchor(index) }) + } + } + } + } + } +} + +@Composable +private fun RailEntry(section: WardrobeSection, active: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val color = if (active) theme.accentTextColor else theme.textColor + Row( + modifier = Modifier.fillMaxWidth().height(33.dp) + .clip(ppShape(8.dp)) + .background( + when { + active -> Accent + hovered -> theme.chipBackground + else -> Color.Transparent + }, + ) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(section.icon, color = color, modifier = Modifier.size(16.dp)) + SocialText(section.label, color = color, fontSize = 14.sp) + } +} + +@Composable +private fun RailAnchor(label: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + SocialText( + label, + color = if (hovered) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(vertical = 3.dp), + ) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt new file mode 100644 index 00000000..39575408 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -0,0 +1,237 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun WardrobeShell( + items: List, + ownedIds: Set, + cart: List, + status: String?, + variantPicks: Map, + auraColor: Int?, + historyReloadKey: Int, + onStatus: (String) -> Unit, + onRefresh: () -> Unit, + onPreviewAuraColor: (Int) -> Unit, + onSelectAuraColor: (Int?) -> Unit, + onSelectVariant: (Int, Int) -> Unit, + onEquip: (CosmeticUiItem) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyBundle: (BundleInfo) -> Unit, + onRemoveFromCart: (String) -> Unit, + onCheckout: () -> Unit, + polyPlusAvailable: Boolean = true, +) { + val store = remember { WardrobeStoreData() } + var storeReloadKey by remember { mutableIntStateOf(0) } + LaunchedEffect(storeReloadKey) { if (polyPlusAvailable) store.load() else store.loading = false } + LaunchedEffect(Unit) { MojangCapeService.refreshIfNeeded() } + + val sections = if (polyPlusAvailable) WardrobeSection.entries else listOf(WardrobeSection.Cosmetics) + var section by remember { mutableStateOf(sections.first()) } + val query = ShellState.searchQuery + var sort by remember { mutableStateOf(WardrobeSort.Newest) } + var selection by remember { mutableStateOf(null) } + var showCart by remember { mutableStateOf(false) } + var showHistory by remember { mutableStateOf(false) } + val storeVariantPicks = remember { mutableStateMapOf() } + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val mojangState = MojangCapeService.state + + val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort) { + buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable) + } + val anchors = remember(entries) { + entries.mapIndexedNotNull { index, entry -> + (entry as? WardrobeEntry.Divider)?.takeIf { it.anchor }?.let { it.label to index } + } + } + LaunchedEffect(section) { + gridState.scrollToItem(0) + } + + val selectedOwned = (selection as? WardrobeSelection.Owned)?.let { sel -> items.firstOrNull { it.groupId == sel.groupId && it.type == sel.type } } + val selectedShop = (selection as? WardrobeSelection.Shop)?.let { sel -> store.all.firstOrNull { it.id == sel.infoId } } + val selectedMojang = (selection as? WardrobeSelection.Mojang)?.let { sel -> + (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == sel.capeId } + } + val selectedBundle = (selection as? WardrobeSelection.Bundle)?.let { sel -> store.bundles.firstOrNull { it.id == sel.bundleId } } + + Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + WardrobeRail( + sections = sections, + selected = section, + anchors = anchors, + modifier = Modifier.width(150.dp).fillMaxHeight(), + onSelect = { + section = it + showHistory = false + }, + onAnchor = { index -> scope.launch { gridState.animateScrollToItem(index) } }, + ) + + Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + WardrobeHeader( + title = if (showHistory) "History" else section.label, + sort = sort, + cartSize = cart.size, + showingHistory = showHistory, + storeEnabled = polyPlusAvailable, + onCycleSort = { sort = WardrobeSort.entries[(sort.ordinal + 1) % WardrobeSort.entries.size] }, + onToggleHistory = { showHistory = !showHistory }, + onRefresh = { + onRefresh() + storeReloadKey++ + }, + onCart = { showCart = !showCart }, + ) + if (showHistory) { + HistoryScreen(ownedIds = ownedIds, reloadKey = historyReloadKey) + } else { + WardrobeGrid( + entries = entries, + state = gridState, + ownedIds = ownedIds, + cartKeys = cart.map { it.key }.toSet(), + selection = selection, + storeVariantPicks = storeVariantPicks, + mojangBusy = MojangCapeService.busy, + modifier = Modifier.weight(1f).fillMaxWidth(), + coverFor = { item -> + store.all.firstOrNull { info -> info.variantList.any { v -> item.variants.any { it.id == v.id } } }?.coverAssetId + }, + onOwned = { item -> + selection = WardrobeSelection.Owned(item.groupId, item.type) + showCart = false + onEquip(item) + }, + onShop = { info -> + val ownedItem = items.firstOrNull { item -> item.owned && info.variantList.any { v -> item.variants.any { it.id == v.id } } } + selection = if (ownedItem != null) WardrobeSelection.Owned(ownedItem.groupId, ownedItem.type) else WardrobeSelection.Shop(info.id) + showCart = false + }, + onAddToCart = onAddToCart, + onMojang = { cape -> + selection = WardrobeSelection.Mojang(cape.id) + showCart = false + toggleMojangCape(cape, onStatus) + }, + onBundle = { bundle -> + selection = WardrobeSelection.Bundle(bundle.id) + showCart = false + }, + onOpenStore = { section = WardrobeSection.Featured }, + ) + } + } + + Box(modifier = Modifier.width(320.dp).fillMaxHeight()) { + when { + showCart -> WardrobeCartPane( + cart = cart, + status = status, + onClose = { showCart = false }, + onRemove = onRemoveFromCart, + onCheckout = onCheckout, + ) + selectedShop != null -> { + val variant = selectedStoreVariant(selectedShop, storeVariantPicks, ownedIds) + StoreDetailPanel( + info = selectedShop, + variant = variant, + status = status, + ownedIds = ownedIds, + inCart = cosmeticCartKey(variant.id) in cart.map { it.key }, + onSelectVariant = { storeVariantPicks[selectedShop.id] = it }, + onAddToCart = onAddToCart, + onBuyNow = onBuyNow, + modifier = Modifier.fillMaxSize(), + ) + } + selectedBundle != null -> BundleDetailPanel( + bundle = selectedBundle, + status = status, + onBuy = { onBuyBundle(selectedBundle) }, + modifier = Modifier.fillMaxSize(), + ) + else -> { + val showBack = selectedMojang != null || selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || + (selection == null && section == WardrobeSection.Cosmetics) + PreviewPanel( + selected = selectedOwned?.takeIf { selectedMojang == null }, + heading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION }, + status = status, + selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, + onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, + showBack = showBack, + showAuraColor = selectedOwned?.type == CosmeticType.Aura, + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} + +@Composable +internal fun MinecraftCapesOnlyScreen() { + var status by remember { mutableStateOf(null) } + WardrobeShell( + items = emptyList(), + ownedIds = emptySet(), + cart = emptyList(), + status = status, + variantPicks = emptyMap(), + auraColor = null, + historyReloadKey = 0, + onStatus = { status = it }, + onRefresh = { PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } }, + onPreviewAuraColor = {}, + onSelectAuraColor = {}, + onSelectVariant = { _, _ -> }, + onEquip = {}, + onAddToCart = { _, _ -> }, + onBuyNow = { _, _ -> }, + onBuyBundle = {}, + onRemoveFromCart = {}, + onCheckout = {}, + polyPlusAvailable = false, + ) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt new file mode 100644 index 00000000..98d9445d --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -0,0 +1,176 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.polyfrost.polyplus.client.cosmetics.BundleCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticStore +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +internal class WardrobeStoreData { + var byType by mutableStateOf>>(emptyMap()) + var popular by mutableStateOf>(emptyList()) + var newest by mutableStateOf>(emptyList()) + var bundles by mutableStateOf>(emptyList()) + var loading by mutableStateOf(true) + var failed by mutableStateOf(false) + + val all: List get() = byType.values.flatten() + popular + newest + + suspend fun load() { + loading = true + val types = CosmeticType.entries.filter { it != CosmeticType.Unknown } + coroutineScope { + val perType = types.map { type -> + async { + type to CosmeticStore.search( + perPage = CosmeticStore.MAX_PAGE_SIZE, + types = listOf(type.serializedName), + reportFailures = false, + ).getOrNull() + } + } + val popularResult = async { CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Popularity) } + val newestResult = async { CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Newest) } + val bundleResult = async { BundleCatalog.search(perPage = FEATURED_ROW_SIZE) } + + val fetched = perType.awaitAll() + byType = fetched.mapNotNull { (type, response) -> response?.let { type to it.results } }.toMap() + popular = popularResult.await().getOrNull()?.results.orEmpty() + newest = newestResult.await().getOrNull()?.results.orEmpty() + bundles = bundleResult.await().getOrNull()?.bundles.orEmpty().filter { it.purchasable } + failed = fetched.all { it.second == null } + } + loading = false + } +} + +private const val FEATURED_ROW_SIZE = 6 + +internal fun buildWardrobeEntries( + section: WardrobeSection, + items: List, + ownedIds: Set, + store: WardrobeStoreData, + mojangState: MojangCapeState, + query: String, + sort: WardrobeSort, + polyPlusAvailable: Boolean, +): List { + val needle = query.trim() + fun matches(name: String) = needle.isEmpty() || name.contains(needle, ignoreCase = true) + fun owned(type: CosmeticType) = items.filter { it.owned && it.type == type && matches(it.name) } + fun shop(type: CosmeticType) = store.byType[type].orEmpty() + .filter { !it.anyOwned(ownedIds) && matches(it.name) } + .sortedFor(sort) + + val entries = mutableListOf() + when (section) { + WardrobeSection.Featured -> { + val popular = store.popular.filter { matches(it.name) } + val newest = store.newest.filter { matches(it.name) } + val bundles = store.bundles.filter { matches(it.name) } + if (popular.isNotEmpty()) { + entries += WardrobeEntry.Divider("Popular", anchor = true) + popular.mapTo(entries) { WardrobeEntry.Shop(it, "popular") } + } + if (newest.isNotEmpty()) { + entries += WardrobeEntry.Divider("New", anchor = true) + newest.mapTo(entries) { WardrobeEntry.Shop(it, "new") } + } + if (bundles.isNotEmpty()) { + entries += WardrobeEntry.Divider("Bundles", anchor = true) + bundles.mapTo(entries) { WardrobeEntry.Bundle(it) } + } + if (entries.isEmpty()) { + entries += WardrobeEntry.Note( + "featured", + when { + store.loading -> "Loading the store..." + store.failed -> "Couldn't reach the PolyPlus store." + else -> "Nothing featured right now." + }, + ) + } + } + + WardrobeSection.Outfits -> + entries += WardrobeEntry.Note("outfits", "Saved outfits aren't available yet.") + + WardrobeSection.Skins -> + entries += WardrobeEntry.Note("skins", "The skin library isn't available yet.") + + WardrobeSection.Emotes -> { + val ownedEmotes = owned(CosmeticType.Emote) + if (ownedEmotes.isNotEmpty()) { + entries += WardrobeEntry.Divider("Your emotes", anchor = true) + ownedEmotes.mapTo(entries) { WardrobeEntry.Owned(it) } + } + shop(CosmeticType.Emote) + .groupBy { info -> info.tags.category.firstOrNull()?.replaceFirstChar { it.uppercase() } ?: "More emotes" } + .forEach { (category, infos) -> + entries += WardrobeEntry.Divider(category, anchor = true) + infos.mapTo(entries) { WardrobeEntry.Shop(it, "emote") } + } + if (entries.isEmpty()) { + entries += WardrobeEntry.Note("emotes", if (store.loading) "Loading emotes..." else "No emotes found.") + } + } + + WardrobeSection.Cosmetics -> for (type in WARDROBE_COSMETIC_TYPES) { + val ownedItems = owned(type) + val shopItems = shop(type) + if (type == CosmeticType.Cape) { + entries += WardrobeEntry.Divider(type.displayName, anchor = true) + entries += WardrobeEntry.ForceCapes + entries += WardrobeEntry.Group( + "minecraft", + "Minecraft", + "Everyone sees this cape, including players without PolyPlus.", + ) + when (mojangState) { + MojangCapeState.Idle, MojangCapeState.Loading -> + entries += WardrobeEntry.Note("minecraft", "Loading your Minecraft capes...") + is MojangCapeState.Unavailable -> + entries += WardrobeEntry.Note("minecraft", mojangState.reason) + is MojangCapeState.Loaded -> { + val capes = mojangState.capes.filter { matches(it.name) } + if (capes.isEmpty()) { + entries += WardrobeEntry.Note("minecraft", "You don't own any Minecraft capes.") + } else { + capes.mapTo(entries) { WardrobeEntry.Mojang(it) } + } + } + } + entries += WardrobeEntry.Group( + "polyplus", + "PolyPlus", + "Players with PolyPlus see this cape instead of your Minecraft cape.", + ) + if (!polyPlusAvailable) { + entries += WardrobeEntry.Note( + "polyplus", + "PolyPlus capes need the Terms of Service and Privacy Policy. Accept them under Privacy in the PolyPlus settings.", + ) + } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { + entries += WardrobeEntry.Note("polyplus", "No PolyPlus capes found.") + } + } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { + continue + } else { + entries += WardrobeEntry.Divider(type.displayName, anchor = true) + } + ownedItems.mapTo(entries) { WardrobeEntry.Owned(it) } + shopItems.mapTo(entries) { WardrobeEntry.Shop(it, type.serializedName) } + } + } + return entries +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt new file mode 100644 index 00000000..c91f412b --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt @@ -0,0 +1,299 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +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.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Locale +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun PriceLabel(basePrice: Float?, finalPrice: Float?, discounted: Boolean) { + when { + finalPrice == null -> SocialText("—", color = LocalTheme.current.textColorSecondary, fontSize = 14.sp) + finalPrice <= 0f -> SocialText("FREE", color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) + discounted && basePrice != null -> { + SocialText(money(basePrice), color = Color(0xFFFF4444), fontSize = 10.sp, textDecoration = TextDecoration.LineThrough) + Spacer(Modifier.width(4.dp)) + SocialText(money(finalPrice), color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + else -> SocialText(money(finalPrice), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +internal fun PriceLabel(info: CosmeticStoreInfo) = PriceLabel(info.basePrice, info.finalPrice, info.discounted) + +@Composable +internal fun PriceLabel(entry: CartEntry) = PriceLabel(entry.basePrice, entry.finalPrice, entry.discounted) + +@Composable +private fun CartPanel( + items: List, + status: String?, + modifier: Modifier, + onRemove: (String) -> Unit, + onCheckout: () -> Unit, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(11.dp)) { + if (items.isEmpty()) { + CenteredNote("Your cart is empty.") + return@Column + } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + items(items, key = { it.key }) { item -> + CartRow(item = item, onRemove = { onRemove(item.key) }) + } + } + + val subtotal = items.sumOf { (it.basePrice ?: 0f).toDouble() }.toFloat() + val total = items.sumOf { (it.finalPrice ?: it.basePrice ?: 0f).toDouble() }.toFloat() + val discount = (subtotal - total).coerceAtLeast(0f) + RowTotals("Subtotal", money(subtotal), LocalTheme.current.textColor) + if (discount > 0f) { + RowTotals("Discounts", "-${money(discount)}", Color(0xFF239A60)) + } + RowTotals("Total", if (total <= 0f) "FREE" else money(total), Color.White, large = true) + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + SmallButton( + label = "Checkout ${items.size} item${if (items.size == 1) "" else "s"}", + iconPath = "assets/polyplus/ico/shopping-cart/0.svg", + primary = true, + onClick = onCheckout, + ) + } +} + +@Composable +private fun CartRow(item: CartEntry, onRemove: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(81.dp) + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(horizontal = 13.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + CoverThumbnail(item.coverAssetId, Modifier.size(58.dp)) + Column(Modifier.weight(1f)) { + SocialText(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + item.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + Column(horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(item) } + if (item.discounted) SocialText("SAVE ${item.discountRate}%", color = Color(0xFF239A60), fontSize = 12.sp) + SocialText("Remove", color = Color(0xFFFF4444), fontSize = 12.sp, modifier = Modifier.clickable(onClick = onRemove)) + } + } +} + +@Composable +private fun RowTotals(label: String, value: String, color: Color, large: Boolean = false) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SocialText(label, color = LocalTheme.current.textColor, fontSize = if (large) 18.sp else 12.sp, modifier = Modifier.weight(1f)) + SocialText(value, color = color, fontSize = if (large) 22.sp else 12.sp, fontWeight = if (large) FontWeight.Medium else FontWeight.Normal) + } +} + +@Composable +internal fun StoreDetailPanel( + info: CosmeticStoreInfo?, + variant: CosmeticVariantUi?, + status: String?, + ownedIds: Set, + inCart: Boolean, + onSelectVariant: (Int) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + modifier: Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { + Box( + modifier = Modifier.fillMaxWidth().height(330.dp) + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + if (info == null || variant == null) { + CenteredNote("Select a cosmetic to preview it.") + } else { + val (source, _) = rememberCosmeticPreviewSource(variant.id, info.type) + val fades = info.type != CosmeticType.Boots + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = source ?: PlayerPreviewSource.LocalLive, + autoSpin = false, + bottomFadeFraction = if (fades) STORE_PREVIEW_FADE_FRACTION else 0f, + verticalAnchor = when (info.type) { + CosmeticType.Hat -> 0.70f + CosmeticType.Boots -> 0.44f + else -> 0.56f + }, + initialYaw = FRONT_YAW_DEG, + previewKey = "store-detail-${variant.id}", + live = true, + ) + OwnershipBadge(variant.id in ownedIds, info.createdAt, Modifier.padding(12.dp)) + } + } + + Box( + modifier = Modifier.weight(1f).fillMaxWidth() + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + if (info == null || variant == null) { + CenteredNote("Browse the store to buy individual cosmetics.") + } else { + val variants = info.uiVariants() + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column( + modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SocialText(info.type.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + SocialText(info.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(info) } + if (variants.size > 1) { + VariantPicker( + variants = variants, + selectedVariantId = variant.id, + onSelect = onSelectVariant, + ownedVariantIds = ownedIds, + ) + } + if (info.tags.all.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (tag in info.tags.all.take(4)) { + Box( + modifier = Modifier.clip(ppShape(5.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + SocialText(tag.replaceFirstChar { it.uppercase() }, color = LocalTheme.current.textColor, fontSize = 11.sp) + } + } + } + } + info.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + if (variant.id in ownedIds) { + SocialText( + "You already own this cosmetic.", + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton( + label = if (inCart) "In cart" else "Add to cart", + iconPath = "assets/polyplus/ico/shopping-cart/0.svg", + primary = false, + onClick = { if (!inCart) onAddToCart(info, variant) }, + ) + SmallButton( + label = "Buy now", + iconPath = "assets/polyplus/ico/shopping-bag.svg", + primary = true, + onClick = { onBuyNow(info, variant) }, + ) + } + } + } + } + } + } +} + +internal fun money(amount: Float, currency: String? = null): String { + val formatted = String.format(Locale.ROOT, "%.2f", amount) + return if (currency == null || currency.equals("usd", ignoreCase = true)) "$$formatted" else "$formatted ${currency.uppercase()}" +} + +@Composable +internal fun WardrobeCartPane( + cart: List, + status: String?, + onClose: () -> Unit, + onRemove: (String) -> Unit, + onCheckout: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SocialText("Cart", color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + SmallButton("Close", iconPath = "close", primary = false, onClick = onClose) + } + CartPanel(items = cart, status = status, modifier = Modifier.weight(1f).fillMaxWidth(), onRemove = onRemove, onCheckout = onCheckout) + } +} + +@Composable +internal fun BundleDetailPanel(bundle: BundleInfo, status: String?, onBuy: () -> Unit, modifier: Modifier) { + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + CoverThumbnail(bundle.assetId, Modifier.fillMaxWidth().height(200.dp)) + SocialText("Bundle", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + SocialText(bundle.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(bundle.basePrice, bundle.finalPrice, bundle.discounted) } + bundle.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + Spacer(Modifier.weight(1f)) + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + SmallButton("Buy now", iconPath = "assets/polyplus/ico/shopping-bag.svg", primary = true, onClick = onBuy) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt new file mode 100644 index 00000000..41629cdd --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt @@ -0,0 +1,201 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.rememberCoverImage +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.ClientPlatform + +@Composable +internal fun CosmeticThumbnail( + source: PlayerPreviewSource?, + type: CosmeticType, + previewKey: String, + modifier: Modifier, + checker: Boolean = true, + coverAssetId: Int? = null, +) { + val framing = cosmeticPreviewFraming(type) + Box(modifier) { + if (checker) CheckerThumbnail(Modifier.fillMaxSize()) + if (source == null && coverAssetId != null) { + CoverThumbnail(coverAssetId, Modifier.fillMaxSize()) + } + if (source != null) { + PlayerPreview( + Modifier.fillMaxSize(), + source = source, + autoSpin = false, + allowDrag = false, + modelScale = framing.modelScale, + verticalAnchor = framing.verticalAnchor, + initialYaw = framing.yawDeg, + previewKey = previewKey, + live = needsLivePreview(type, source), + ) + } + } +} + +private fun needsLivePreview(type: CosmeticType, source: PlayerPreviewSource) = when (type) { + CosmeticType.Aura -> true + CosmeticType.Cape -> + (source as? PlayerPreviewSource.Override)?.capeCosmeticId?.let(CosmeticAssetCache::isCapeAnimated) == true + else -> false +} + +private data class PreviewFraming(val yawDeg: Float, val modelScale: Float, val verticalAnchor: Float) + +internal const val FRONT_YAW_DEG = 180f + 22.9f +internal const val BACK_YAW_DEG = 22.9f +internal const val STORE_PREVIEW_FADE_FRACTION = 0.22f + +private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (type) { + CosmeticType.Hat, CosmeticType.Glasses -> PreviewFraming(FRONT_YAW_DEG, 1.0f, 1.5f) + CosmeticType.Boots -> PreviewFraming(FRONT_YAW_DEG, 0.85f, 0.26f) + CosmeticType.Shoulder -> PreviewFraming(FRONT_YAW_DEG, 0.58f, 0.62f) + CosmeticType.Wings -> PreviewFraming(BACK_YAW_DEG, 0.5f, 0.5f) + CosmeticType.Backpack -> PreviewFraming(BACK_YAW_DEG, 0.55f, 0.55f) + CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.42f, 0.5f) + CosmeticType.Aura -> PreviewFraming(FRONT_YAW_DEG, 0.5f, 0.5f) + CosmeticType.Glove -> PreviewFraming(FRONT_YAW_DEG, 0.7f, 0.42f) + else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) +} + +@Composable +internal fun rememberCosmeticPreviewSource(item: CosmeticUiItem): Pair { + val cosmeticId = item.equippedVariantId ?: item.variants.firstOrNull()?.id ?: return null to 0 + return rememberCosmeticPreviewSource(cosmeticId, item.type) +} + +@Composable +internal fun rememberCosmeticPreviewSource(cosmeticId: Int, type: CosmeticType): Pair { + val isCape = type == CosmeticType.Cape + val isPet = type == CosmeticType.Pet + + var previewId by remember(cosmeticId) { mutableIntStateOf(cosmeticId) } + val loaded = CosmeticAssetCache.installs.let { isPreviewAssetLoaded(previewId, isCape, isPet) } + // a trim can evict a preview's assets while the screen is closed, so it reloads them when shown again + LaunchedEffect(cosmeticId, loaded) { + val slim = ClientPlatform.runOnMainSync { ClientPlatform.localSkinSlim() } + val id = CosmeticCatalog.resolveVariantForSkin(cosmeticId, slim) + previewId = id + if (!isPreviewAssetLoaded(id, isCape, isPet)) CosmeticAssetCache.ensureCosmeticLoaded(id) + } + + val loadTick = (if (loaded) 1 else 0) + (if (isCape && CosmeticAssetCache.isCapeAnimated(previewId)) 2 else 0) + + val source = remember(previewId, loadTick, isCape, isPet) { + when { + isCape -> PlayerPreviewSource.Override(CosmeticEquipment(), capeCosmeticId = previewId) + isPet -> { + val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) + if (attached != null) { + val equipment = CosmeticEquipment() + equipment.equip(attached) + PlayerPreviewSource.Override(equipment) + } else { + val pet = CosmeticAssetCache.getPetDefinition(previewId) ?: return@remember null + PlayerPreviewSource.Override(CosmeticEquipment(), pet = pet) + } + } + else -> { + val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) ?: return@remember null + val equipment = CosmeticEquipment() + equipment.equip(attached) + PlayerPreviewSource.Override(equipment) + } + } + } + + return source to loadTick +} + +@Composable +internal fun BoxScope.OwnershipBadge(owned: Boolean, createdAt: String, modifier: Modifier) { + val label = when { + owned -> "OWNED" + isNewItem(createdAt) -> "NEW" + else -> return + } + Box( + modifier = modifier.align(Alignment.TopStart) + .clip(ppShape(5.dp)) + .background(if (owned) Color(0xFF239A60) else Accent) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) + } +} + +private fun isPreviewAssetLoaded(id: Int, isCape: Boolean, isPet: Boolean): Boolean = when { + isCape -> CosmeticAssetCache.isCapeLoaded(id) + isPet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null + else -> CosmeticAssetCache.getAttachedCosmetic(id) != null +} + +internal fun isNewItem(createdAt: String): Boolean { + if (createdAt.isBlank()) return false + return runCatching { + val created = OffsetDateTime.parse(createdAt).toInstant() + created.isAfter(Instant.now().minus(Duration.ofDays(7))) + }.getOrDefault(false) +} + +@Composable +internal fun CoverThumbnail(coverAssetId: Int?, modifier: Modifier) { + val cover = rememberCoverImage(coverAssetId) + Box(modifier) { + CheckerThumbnail(Modifier.fillMaxSize()) + if (cover != null) { + Image( + bitmap = cover, + contentDescription = null, + filterQuality = FilterQuality.None, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().clip(ppShape(5.dp)), + ) + } + } +} + +@Composable +private fun CheckerThumbnail(modifier: Modifier) { + Checkerboard( + modifier = modifier.clip(ppShape(5.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)), + cell = 9.dp, + ) +} From cd81e0ba83ca99561192609ea0d0e3acb8dccf0d Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 12:41:13 +0200 Subject: [PATCH 04/22] feat: add an emote wheel editor with multiple wheels --- .../polyplus/client/emotes/EmoteWheels.kt | 103 +++++++++ .../polyplus/client/gui/EmoteWheelScreen.kt | 30 ++- .../client/gui/wardrobe/WardrobeHeader.kt | 34 +++ .../client/gui/wardrobe/WardrobeShell.kt | 126 +++++++---- .../gui/wardrobe/WardrobeWheelEditor.kt | 209 ++++++++++++++++++ 5 files changed, 453 insertions(+), 49 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt new file mode 100644 index 00000000..d1732a04 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt @@ -0,0 +1,103 @@ +package org.polyfrost.polyplus.client.emotes + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.serialization.Serializable +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import java.io.File + +object EmoteWheels { + const val SLOT_COUNT = 8 + const val MAX_WHEELS = 8 + + private val LOGGER = LogManager.getLogger("polyplus/emote-wheels") + private val file = File("${PolyPlusConstants.NAME}/emote-wheels.json") + + @Serializable + private data class Stored(val active: Int = 0, val wheels: List> = emptyList()) + + private var stored by mutableStateOf(load()) + + val wheelCount: Int get() = stored.wheels.size.coerceAtLeast(1) + + val activeIndex: Int get() = stored.active.coerceIn(0, wheelCount - 1) + + fun slots(index: Int = activeIndex): List { + val owned = CosmeticCatalog.ownedEmoteIds() + val wheel = stored.wheels.getOrNull(index) ?: return defaultSlots(owned) + return List(SLOT_COUNT) { slot -> wheel.getOrNull(slot)?.takeIf { it in owned } } + } + + fun assign(index: Int, slot: Int, emoteId: Int?) { + if (slot !in 0 until SLOT_COUNT) return + val wheels = materialized().toMutableList() + while (wheels.size <= index) wheels += List(SLOT_COUNT) { null } + val next = wheels[index].toMutableList() + if (emoteId != null) { + for (i in next.indices) if (next[i] == emoteId) next[i] = null + } + next[slot] = emoteId + wheels[index] = next + update(stored.copy(wheels = wheels)) + } + + fun addToFirstEmpty(index: Int, emoteId: Int): Int? { + val current = slots(index) + if (emoteId in current) return current.indexOf(emoteId) + val slot = current.indexOfFirst { it == null }.takeIf { it >= 0 } ?: return null + assign(index, slot, emoteId) + return slot + } + + fun addWheel(): Boolean { + if (wheelCount >= MAX_WHEELS) return false + val wheels = materialized() + listOf(List(SLOT_COUNT) { null }) + update(Stored(active = wheels.lastIndex, wheels = wheels)) + return true + } + + fun removeWheel(index: Int) { + val wheels = materialized() + if (wheels.size <= 1 || index !in wheels.indices) return + val next = wheels.filterIndexed { i, _ -> i != index } + update(Stored(active = activeIndex.coerceAtMost(next.lastIndex), wheels = next)) + } + + fun setActive(index: Int) { + if (index == activeIndex || index !in 0 until wheelCount) return + update(stored.copy(active = index)) + } + + fun cycle(delta: Int) { + if (wheelCount <= 1) return + setActive(Math.floorMod(activeIndex + delta, wheelCount)) + } + + private fun defaultSlots(owned: Set): List { + val ids = CosmeticCatalog.allEmoteDefinitions() + .filter { it.id in owned } + .sortedBy { it.id } + .take(SLOT_COUNT) + .map { it.id } + return List(SLOT_COUNT) { ids.getOrNull(it) } + } + + private fun materialized(): List> = + stored.wheels.ifEmpty { listOf(slots(0)) } + + private fun update(next: Stored) { + stored = next + runCatching { + file.parentFile?.mkdirs() + file.writeText(PolyPlusClient.JSON.encodeToString(Stored.serializer(), next)) + }.onFailure { LOGGER.warn("Failed to save emote wheels", it) } + } + + private fun load(): Stored = runCatching { + if (file.isFile) PolyPlusClient.JSON.decodeFromString(Stored.serializer(), file.readText()) else Stored() + }.onFailure { LOGGER.warn("Failed to read emote wheels; starting fresh", it) }.getOrDefault(Stored()) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt index b4aaf90b..276668e0 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt @@ -49,6 +49,7 @@ import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.emotes.EmoteWheels import kotlin.math.atan2 import kotlin.math.sqrt import kotlinx.coroutines.isActive @@ -134,14 +135,12 @@ class EmoteWheelScreen : ComposeScreen(RenderMode.CONTINUOUS) { @OptIn(ExperimentalComposeUiApi::class) @Composable private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { - val slots = remember { - val ownedIds = CosmeticCatalog.ownedEmoteIds() - val owned = CosmeticCatalog.allEmoteDefinitions() - .filter { it.id in ownedIds } - .sortedBy { it.id } - .take(SLOT_COUNT) - .map { WheelSlot(it.id, it.name) } - List(SLOT_COUNT) { index -> owned.getOrNull(index) } + val wheelIndex = EmoteWheels.activeIndex + val wheelCount = EmoteWheels.wheelCount + val slots = remember(wheelIndex, wheelCount) { + EmoteWheels.slots(wheelIndex).map { id -> + id?.let { emoteId -> CosmeticCatalog.getEmoteDefinition(emoteId)?.let { WheelSlot(it.id, it.name) } } + } } var mouseXRoot by remember { mutableFloatStateOf(0f) } @@ -192,6 +191,10 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { val pos = event.changes.firstOrNull()?.position ?: return@onPointerEvent mouseXRoot = pos.x + rootPos.x mouseYRoot = pos.y + rootPos.y + } + .onPointerEvent(PointerEventType.Scroll) { event -> + val delta = event.changes.firstOrNull()?.scrollDelta?.y ?: return@onPointerEvent + if (delta != 0f) EmoteWheels.cycle(if (delta > 0f) 1 else -1) }, contentAlignment = Alignment.Center, ) { @@ -204,6 +207,17 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { ) val hoveredName = slots.getOrNull(hoveredSlot)?.name + if (hoveredName == null && wheelCount > 1) { + BasicText( + text = "Wheel ${wheelIndex + 1} of $wheelCount", + style = TextStyle( + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + fontFamily = LocalTheme.current.typography.family, + textAlign = TextAlign.Center, + ), + ) + } if (hoveredName != null) { Box( modifier = Modifier.widthIn(max = INNER_DIAMETER - 48.dp), diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt index e924893e..481c514c 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size @@ -23,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape @@ -81,3 +83,35 @@ private fun HeaderIconButton(iconPath: String, tooltip: String, onClick: () -> U if (hovered) TooltipPopup(tooltip) } } + +@Composable +internal fun PreviewModeToggle(wheelMode: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PreviewModeOption("Character", "profiles", selected = !wheelMode, modifier = Modifier.weight(1f), onClick = { onChange(false) }) + PreviewModeOption("Emote wheel", "dots-grid", selected = wheelMode, modifier = Modifier.weight(1f), onClick = { onChange(true) }) + } +} + +@Composable +private fun PreviewModeOption(label: String, iconPath: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val color = if (selected) theme.accentTextColor else theme.textColor + Row( + modifier = modifier.fillMaxHeight() + .clip(ppShape(8.dp)) + .background(if (selected) Accent else theme.chipBackground) + .border(1.dp, if (hovered && !selected) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(iconPath, color = color, modifier = Modifier.size(14.dp)) + SocialText(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index 39575408..511f0f42 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.emotes.EmoteWheels import org.polyfrost.polyplus.client.cosmetics.MojangCapeState import org.polyfrost.polyplus.client.network.http.responses.BundleInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo @@ -67,6 +68,8 @@ internal fun WardrobeShell( var selection by remember { mutableStateOf(null) } var showCart by remember { mutableStateOf(false) } var showHistory by remember { mutableStateOf(false) } + var wheelMode by remember { mutableStateOf(false) } + var wheelSlot by remember { mutableStateOf(null) } val storeVariantPicks = remember { mutableStateMapOf() } val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() @@ -135,9 +138,28 @@ internal fun WardrobeShell( store.all.firstOrNull { info -> info.variantList.any { v -> item.variants.any { it.id == v.id } } }?.coverAssetId }, onOwned = { item -> - selection = WardrobeSelection.Owned(item.groupId, item.type) - showCart = false - onEquip(item) + if (wheelMode && item.type == CosmeticType.Emote) { + val wheel = EmoteWheels.activeIndex + val target = wheelSlot + val placed = if (target != null) { + EmoteWheels.assign(wheel, target, item.groupId) + target + } else { + EmoteWheels.addToFirstEmpty(wheel, item.groupId) + } + wheelSlot = null + onStatus( + if (placed == null) { + "Wheel ${wheel + 1} is full. Pick a slot to replace." + } else { + "Added ${item.name} to wheel ${wheel + 1}." + }, + ) + } else { + selection = WardrobeSelection.Owned(item.groupId, item.type) + showCart = false + onEquip(item) + } }, onShop = { info -> val ownedItem = items.firstOrNull { item -> item.owned && info.variantList.any { v -> item.variants.any { it.id == v.id } } } @@ -159,51 +181,73 @@ internal fun WardrobeShell( } } - Box(modifier = Modifier.width(320.dp).fillMaxHeight()) { - when { - showCart -> WardrobeCartPane( - cart = cart, - status = status, - onClose = { showCart = false }, - onRemove = onRemoveFromCart, - onCheckout = onCheckout, + Column(modifier = Modifier.width(320.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (polyPlusAvailable) { + PreviewModeToggle( + wheelMode = wheelMode, + onChange = { wheel -> + wheelMode = wheel + wheelSlot = null + if (wheel) { + section = WardrobeSection.Emotes + showHistory = false + showCart = false + } + }, ) - selectedShop != null -> { - val variant = selectedStoreVariant(selectedShop, storeVariantPicks, ownedIds) - StoreDetailPanel( - info = selectedShop, - variant = variant, + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + showCart -> WardrobeCartPane( + cart = cart, status = status, - ownedIds = ownedIds, - inCart = cosmeticCartKey(variant.id) in cart.map { it.key }, - onSelectVariant = { storeVariantPicks[selectedShop.id] = it }, - onAddToCart = onAddToCart, - onBuyNow = onBuyNow, + onClose = { showCart = false }, + onRemove = onRemoveFromCart, + onCheckout = onCheckout, + ) + selectedShop != null -> { + val variant = selectedStoreVariant(selectedShop, storeVariantPicks, ownedIds) + StoreDetailPanel( + info = selectedShop, + variant = variant, + status = status, + ownedIds = ownedIds, + inCart = cosmeticCartKey(variant.id) in cart.map { it.key }, + onSelectVariant = { storeVariantPicks[selectedShop.id] = it }, + onAddToCart = onAddToCart, + onBuyNow = onBuyNow, + modifier = Modifier.fillMaxSize(), + ) + } + wheelMode -> EmoteWheelEditor( + selectedSlot = wheelSlot, + coverForEmote = { id -> store.all.firstOrNull { info -> info.variantList.any { it.id == id } }?.coverAssetId }, + onSelectSlot = { wheelSlot = it }, modifier = Modifier.fillMaxSize(), ) - } - selectedBundle != null -> BundleDetailPanel( - bundle = selectedBundle, - status = status, - onBuy = { onBuyBundle(selectedBundle) }, - modifier = Modifier.fillMaxSize(), - ) - else -> { - val showBack = selectedMojang != null || selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || - (selection == null && section == WardrobeSection.Cosmetics) - PreviewPanel( - selected = selectedOwned?.takeIf { selectedMojang == null }, - heading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION }, + selectedBundle != null -> BundleDetailPanel( + bundle = selectedBundle, status = status, - selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, - onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, - showBack = showBack, - showAuraColor = selectedOwned?.type == CosmeticType.Aura, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, + onBuy = { onBuyBundle(selectedBundle) }, modifier = Modifier.fillMaxSize(), ) + else -> { + val showBack = selectedMojang != null || selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || + (selection == null && section == WardrobeSection.Cosmetics) + PreviewPanel( + selected = selectedOwned?.takeIf { selectedMojang == null }, + heading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION }, + status = status, + selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, + onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, + showBack = showBack, + showAuraColor = selectedOwned?.type == CosmeticType.Aura, + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt new file mode 100644 index 00000000..5fbdbea9 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt @@ -0,0 +1,209 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.emotes.EmoteWheels +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +private val WHEEL_GRID_ORDER: List> = listOf( + 0 to 1, 0 to 2, 1 to 2, 2 to 2, 2 to 1, 2 to 0, 1 to 0, 0 to 0, +) + +@Composable +internal fun EmoteWheelEditor( + selectedSlot: Int?, + coverForEmote: (Int) -> Int?, + onSelectSlot: (Int?) -> Unit, + modifier: Modifier, +) { + val theme = LocalTheme.current + val wheelIndex = EmoteWheels.activeIndex + val wheelCount = EmoteWheels.wheelCount + val slots = EmoteWheels.slots(wheelIndex) + + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WheelIconButton("left-arrow", enabled = wheelCount > 1, onClick = { + EmoteWheels.cycle(-1) + onSelectSlot(null) + }) + SocialText( + "Wheel ${wheelIndex + 1} of $wheelCount", + color = theme.textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + WheelIconButton("right-arrow", enabled = wheelCount > 1, onClick = { + EmoteWheels.cycle(1) + onSelectSlot(null) + }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton("New wheel", iconPath = "plus", primary = false, onClick = { + if (EmoteWheels.addWheel()) onSelectSlot(null) + }) + if (wheelCount > 1) { + SmallButton("Remove", iconPath = "trash", primary = false, onClick = { + EmoteWheels.removeWheel(wheelIndex) + onSelectSlot(null) + }) + } + } + + BoxWithConstraints(Modifier.fillMaxWidth()) { + val cell = (maxWidth - 16.dp) / 3 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (row in 0..2) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (col in 0..2) { + val slot = WHEEL_GRID_ORDER.indexOf(row to col) + if (slot < 0) { + Spacer(Modifier.size(cell)) + } else { + WheelSlotCell( + emoteId = slots.getOrNull(slot), + selected = selectedSlot == slot, + coverForEmote = coverForEmote, + modifier = Modifier.size(cell), + onClick = { onSelectSlot(if (selectedSlot == slot) null else slot) }, + onClear = { EmoteWheels.assign(wheelIndex, slot, null) }, + ) + } + } + } + } + } + } + + SocialText( + if (selectedSlot == null) { + "Click an emote to add it to the wheel, or pick a slot first to choose where it goes." + } else { + "Click an emote to put it in the selected slot." + }, + color = theme.textColorSecondary, + fontSize = 12.sp, + ) + } +} + +@Composable +private fun WheelSlotCell( + emoteId: Int?, + selected: Boolean, + coverForEmote: (Int) -> Int?, + modifier: Modifier, + onClick: () -> Unit, + onClear: () -> Unit, +) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val name = emoteId?.let { CosmeticCatalog.getEmoteDefinition(it)?.name } + Box( + modifier = modifier + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> theme.borderColor + }, + ppShape(8.dp), + ) + .hoverable(interaction) + .clickable(onClick = onClick), + ) { + if (emoteId != null) { + CosmeticThumbnail( + source = null, + type = CosmeticType.Emote, + previewKey = "wheel-$emoteId", + modifier = Modifier.fillMaxSize().padding(bottom = 16.dp), + checker = false, + coverAssetId = coverForEmote(emoteId), + ) + if (name != null) { + CardLabel( + name, + color = theme.textColor, + fontSize = 10.sp, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + if (hovered) { + Box( + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(18.dp) + .clip(ppShape(6.dp)) + .background(theme.popupBackground) + .clickable(onClick = onClear), + contentAlignment = Alignment.Center, + ) { + Icon("close", color = theme.textColor, modifier = Modifier.size(12.dp)) + } + } + } else { + SocialText( + "Empty", + color = theme.textColorSecondary, + fontSize = 10.sp, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +@Composable +private fun WheelIconButton(iconPath: String, enabled: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + Box( + modifier = Modifier.size(28.dp) + .alpha(if (enabled) 1f else 0.4f) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(iconPath, color = theme.textColor, modifier = Modifier.size(14.dp)) + } +} From f9279d4444d328f8e019522c0d8d0f83d04b57b9 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:18 +0200 Subject: [PATCH 05/22] feat: rename the cosmetics screen to Wardrobe and add a keybind to open it --- .../org/polyfrost/polyplus/client/PolyPlusConfig.kt | 13 +++++++++++++ .../polyplus/client/PolyPlusMainMenuConfig.kt | 4 ++-- .../polyplus/client/gui/PolyPlusCosmeticsScreen.kt | 4 ++-- .../polyplus/client/gui/PolyPlusMainMenuScreen.kt | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt index f84b040e..d8003b0c 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt @@ -15,6 +15,7 @@ import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.polyplus.BackendUrl import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.gui.PolyPlusOneConfigIntegration import org.polyfrost.polyplus.client.network.websocket.PolyConnection import org.polyfrost.polyplus.client.privacy.PrivacyEnforcement import org.polyfrost.polyplus.client.social.SocialOverlay @@ -224,6 +225,18 @@ object PolyPlusConfig : Config("${PolyPlusConstants.ID}.json", "${PolyPlusConsta true } + @Keybind( + title = "Open Wardrobe", + subcategory = "Keybinds", + description = "Open the PolyPlus wardrobe", + ) + var wardrobeKeybind = OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { state -> + if (state) { + PolyPlusOneConfigIntegration.openCosmetics() + } + true + } + @Dropdown(title = "API URL", description = "The URL used for the PolyPlus API. Only change if you know what you're doing.") var apiUrl: BackendUrl = BackendUrl.PRODUCTION get() = if (PolyPlusConstants.IS_DEV_ENV) field else BackendUrl.PRODUCTION diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt index 535dfd54..04dc1705 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt @@ -73,8 +73,8 @@ object PolyPlusMainMenuConfig : Config( @JvmStatic @Switch( - title = "Hide Cosmetics Button", - description = "Hide the Cosmetics button on the PolyPlus main menu.", + title = "Hide Wardrobe Button", + description = "Hide the Wardrobe button on the PolyPlus main menu.", subcategory = "Elements", ) var hideMainMenuCosmetics = false diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt index 20560273..65c1ce00 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt @@ -51,7 +51,7 @@ data object PolyPlusCosmeticsRoute object PolyPlusOneConfigIntegration { private val cosmeticsRoute = NavigationRoute( - id = "cosmetics", + id = "wardrobe", icon = "assets/polyplus/ico/stars.svg", route = PolyPlusCosmeticsRoute, ) @@ -98,7 +98,7 @@ fun NavGraphBuilder.polyPlusCosmeticsGraph() { @Composable private fun PolyPlusCosmeticsScreen() { DisposableEffect(Unit) { - ShellState.title = "Cosmetics" + ShellState.title = "Wardrobe" onDispose { } } if (!PrivacyConsent.allowsOnlineServices()) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt index a7aa06c4..318277b3 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -900,7 +900,7 @@ private fun RightColumn(modifier: Modifier, screen: Screen) { } if (!PolyPlusMainMenuConfig.hideMainMenuCosmetics) { PillButton( - "Cosmetics", + "Wardrobe", ASSETS + "diamond-01.svg", Modifier.fillMaxWidth(), onClick = { PolyPlusOneConfigIntegration.openCosmetics() }, From d4794a9abb82ffb33844feffe3004f1ab7c19a7b Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:24 +0200 Subject: [PATCH 06/22] fix: hide the cape in legacy previews when none is selected --- .../polyplus/client/gui/preview/PlayerPreviewRenderer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index 9ea11331..6e16ad1c 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -977,6 +977,9 @@ object PlayerPreviewRenderer { override fun isModelPartShown(part: PlayerModelPart): Boolean = true } + private fun withoutCape(skin: PlayerSkin): PlayerSkin = + PlayerSkin(skin.texture(), skin.textureUrl(), null, skin.elytraTexture(), skin.model(), skin.secure()) + private object PreviewWorld { var cached: ClientLevel? = null private set @@ -1169,6 +1172,7 @@ object PlayerPreviewRenderer { } equipmentByEntityId[LEGACY_PREVIEW_ENTITY_ID] = equipment val state = legacyState(skin, yawDeg) + if (hidesCape(source)) state.showCape = false val renderer = legacyPlayerRenderer(mc, skin) ?: return false //?} //? if < 1.21.4 { @@ -1178,7 +1182,7 @@ object PlayerPreviewRenderer { val player = legacyDummy(mc, level) ?: return false val cape = capeOverride(source) ?: CosmeticAssetCache.getCapeTexture(mc.gameProfile.id) - player.skinOverride = cape?.let { withCape(skin, it) } ?: skin + player.skinOverride = if (hidesCape(source)) withoutCape(skin) else cape?.let { withCape(skin, it) } ?: skin bindLegacyEquipment(player, source) player.setYRot(0f); player.yRotO = 0f player.yBodyRot = yawDeg; player.yBodyRotO = yawDeg From 621844f79e73c9b9e4c795d0364457dbe8d72118 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:24 +0200 Subject: [PATCH 07/22] feat: let player previews share a persistent rotation --- .../client/gui/preview/PlayerPreview.kt | 35 ++++++++++++++----- .../client/gui/preview/PreviewRotation.kt | 12 +++++++ 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt index 913b23b9..cc185421 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt @@ -46,9 +46,10 @@ fun PlayerPreview( previewKey: Any = source, live: Boolean = false, bottomFadeFraction: Float = 0f, + rotation: PreviewRotation? = null, ) { if (live) { - PlayerPreviewLive(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey, bottomFadeFraction) + PlayerPreviewLive(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey, bottomFadeFraction, rotation) return } PlayerPreviewBitmap(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey) @@ -65,15 +66,18 @@ private fun PlayerPreviewLive( initialYaw: Float, previewKey: Any, bottomFadeFraction: Float, + rotation: PreviewRotation?, ) { val entry = remember { PlayerPreviewOffscreen.register() } DisposableEffect(entry) { onDispose { PlayerPreviewOffscreen.unregister(entry) } } - remember(previewKey, initialYaw) { - entry.dragYaw = 0f; entry.dragPitch = 0f - entry.spinAccum = 0f; entry.lastSpinNanos = 0L + if (rotation == null) { + remember(previewKey, initialYaw) { + entry.dragYaw = 0f; entry.dragPitch = 0f + entry.spinAccum = 0f; entry.lastSpinNanos = 0L + } } SideEffect { @@ -81,7 +85,11 @@ private fun PlayerPreviewLive( entry.modelScale = modelScale entry.verticalAnchor = verticalAnchor entry.autoSpin = autoSpin - entry.initialYaw = initialYaw + entry.initialYaw = rotation?.yaw ?: initialYaw + if (rotation != null) { + entry.dragYaw = 0f + entry.dragPitch = rotation.pitch + } } val frameNanos by produceState(0L) { while (true) withFrameNanos { value = it } } @@ -90,15 +98,24 @@ private fun PlayerPreviewLive( val dragModifier: Modifier = if (allowDrag) { - Modifier.pointerInput(entry) { + Modifier.pointerInput(entry, rotation) { detectDragGestures( onDragStart = { entry.dragging = true }, onDragEnd = { entry.dragging = false }, onDragCancel = { entry.dragging = false }, ) { change, drag -> - entry.dragYaw -= drag.x * DRAG_YAW_SENSITIVITY - entry.dragPitch = (entry.dragPitch + drag.y * DRAG_PITCH_SENSITIVITY) - .coerceIn(-MAX_PITCH, MAX_PITCH) + if (rotation != null) { + rotation.yaw -= drag.x * DRAG_YAW_SENSITIVITY + rotation.pitch = (rotation.pitch + drag.y * DRAG_PITCH_SENSITIVITY) + .coerceIn(-MAX_PITCH, MAX_PITCH) + rotation.userRotated = true + entry.initialYaw = rotation.yaw + entry.dragPitch = rotation.pitch + } else { + entry.dragYaw -= drag.x * DRAG_YAW_SENSITIVITY + entry.dragPitch = (entry.dragPitch + drag.y * DRAG_PITCH_SENSITIVITY) + .coerceIn(-MAX_PITCH, MAX_PITCH) + } change.consume() } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt new file mode 100644 index 00000000..6b608796 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt @@ -0,0 +1,12 @@ +package org.polyfrost.polyplus.client.gui.preview + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class PreviewRotation(initialYaw: Float) { + var yaw by mutableFloatStateOf(initialYaw) + var pitch by mutableFloatStateOf(0f) + var userRotated by mutableStateOf(false) +} From 3db3c6caf1ce471790dbd69a6ed264a9cd884f29 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:24 +0200 Subject: [PATCH 08/22] feat: build variant swatch art from cosmetic textures --- .../client/cosmetics/CosmeticAssetCache.kt | 11 +++ .../client/cosmetics/CosmeticSwatches.kt | 98 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt index d3768639..3b76e3d4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt @@ -458,6 +458,17 @@ object CosmeticAssetCache { CosmeticType.Emote -> "emote-$id" else -> "cosmetic-$id" } + + fun primaryTextureFile(id: Int): File? { + val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null + val dir = baseDir.resolve(definition.cacheKey()) + if (!dir.isDirectory) return null + return dir.walkTopDown() + .filter { it.isFile && !it.name.startsWith(APPLE_DOUBLE_PREFIX) && it.extension.equals("png", ignoreCase = true) } + .sortedBy { it.invariantSeparatorsPath } + .firstOrNull() + ?: dir.resolve("asset.bin").takeIf { it.isFile } + } } private const val MAX_CAPE_FRAME_PIXELS = 1024L * 512 diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt new file mode 100644 index 00000000..30e308f2 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt @@ -0,0 +1,98 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.awt.image.BufferedImage +import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO + +sealed interface SwatchArt { + data class Picture(val bitmap: ImageBitmap) : SwatchArt + data class Stripes(val colors: List) : SwatchArt +} + +object CosmeticSwatches { + private val LOGGER = LogManager.getLogger("polyplus/swatches") + private val art = ConcurrentHashMap() + private val requested = ConcurrentHashMap.newKeySet() + + private const val CAPE_TEXTURE_WIDTH = 64 + private const val LEGACY_CAPE_TEXTURE_WIDTH = 22 + private const val MAX_STRIPES = 3 + private const val MIN_STRIPE_SHARE = 0.08 + + var version by mutableIntStateOf(0) + private set + + fun get(id: Int): SwatchArt? { + art[id]?.let { return it } + if (requested.add(id)) load(id) + return null + } + + private fun load(id: Int) { + PolyPlusClient.SCOPE.launch { + runSuspendCatching { + CosmeticAssetCache.ensureCosmeticLoaded(id) + val built = withContext(Dispatchers.IO) { build(id) } ?: return@runSuspendCatching + art[id] = built + version++ + }.onFailure { LOGGER.debug("Could not build a swatch for cosmetic {}", id, it) } + } + } + + private fun build(id: Int): SwatchArt? { + val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null + val file = CosmeticAssetCache.primaryTextureFile(id) ?: return null + val image = ImageIO.read(file) ?: return null + return if (definition.type == CosmeticType.Cape) { + capeFront(image)?.let { SwatchArt.Picture(it.toComposeImageBitmap()) } + } else { + dominantColors(image).takeIf { it.isNotEmpty() }?.let(SwatchArt::Stripes) + } + } + + private fun capeFront(image: BufferedImage): BufferedImage? { + val unit = if (image.width >= CAPE_TEXTURE_WIDTH) { + image.width / CAPE_TEXTURE_WIDTH.toFloat() + } else { + image.width / LEGACY_CAPE_TEXTURE_WIDTH.toFloat() + } + val offset = unit.toInt() + val width = (unit * 10).toInt() + val height = (unit * 16).toInt() + if (width <= 0 || height <= 0 || offset + width > image.width || offset + height > image.height) return null + return image.getSubimage(offset, offset, width, height) + } + + private fun dominantColors(image: BufferedImage): List { + val counts = HashMap() + var total = 0 + for (py in 0 until image.height) { + for (px in 0 until image.width) { + val argb = image.getRGB(px, py) + if ((argb ushr 24) < 128) continue + val bucket = (argb and 0xF0F0F0) or 0x080808 + counts[bucket] = (counts[bucket] ?: 0) + 1 + total++ + } + } + if (total == 0) return emptyList() + return counts.entries + .sortedByDescending { it.value } + .filter { it.value >= total * MIN_STRIPE_SHARE } + .take(MAX_STRIPES) + .map { Color(0xFF000000.toInt() or it.key) } + } +} From f37e8967e420c113653e3a5c42c3dfc569696f76 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:24 +0200 Subject: [PATCH 09/22] feat: detect emote wheel keybind conflicts --- .../client/emotes/EmoteWheelKeybind.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt index 10e6ab60..f4eec537 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt @@ -1,6 +1,11 @@ package org.polyfrost.polyplus.client.emotes import net.minecraft.client.Minecraft +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.localizedTitle +import org.polyfrost.oneconfig.internal.ui.keybind.collectAllKeybindGroups +import org.polyfrost.polyplus.client.PolyPlusConfig import org.polyfrost.polyplus.client.gui.EmoteWheelScreen object EmoteWheelKeybind { @@ -30,4 +35,22 @@ object EmoteWheelKeybind { *///?} } } + + fun isBound(): Boolean = PolyPlusConfig.emoteWheelKeybind.isBound + + fun conflictingKeybindNames(): List { + val wheel = PolyPlusConfig.emoteWheelKeybind + if (!wheel.isBound) return emptyList() + return runCatching { + collectAllKeybindGroups().asSequence() + .flatMap { it.entries.asSequence() } + .mapNotNull { entry -> + val other = entry.prop.get() as? OneConfigKeybind ?: return@mapNotNull null + if (other === wheel || !wheel.conflictsWith(other)) return@mapNotNull null + entry.prop.localizedTitle().asRenderText().ifBlank { entry.path } + } + .distinct() + .toList() + }.getOrDefault(emptyList()) + } } From 68faf09774ff38889f4883ba5440235c21ab508d Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:15:24 +0200 Subject: [PATCH 10/22] feat: add variant swatches, card menus and a popularity sort to the wardrobe Warn when the emote wheel keybind is missing or conflicting, keep the preview rotation across selections, jump straight to rail sections, and restore the previous section when leaving the emote wheel. --- .../client/gui/wardrobe/WardrobeGrid.kt | 276 +++++++++++++++++- .../client/gui/wardrobe/WardrobeModels.kt | 7 + .../client/gui/wardrobe/WardrobePreview.kt | 23 +- .../client/gui/wardrobe/WardrobeShell.kt | 97 ++++-- .../client/gui/wardrobe/WardrobeStoreData.kt | 5 + .../gui/wardrobe/WardrobeStorePanels.kt | 3 + 6 files changed, 377 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt index 296da421..a5587426 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -1,5 +1,6 @@ package org.polyfrost.polyplus.client.gui.wardrobe +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,7 +19,9 @@ 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.requiredSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -26,7 +30,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items 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.alpha @@ -35,15 +41,29 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.navigation.graph.KeybindsGraph import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.CosmeticSwatches import org.polyfrost.polyplus.client.cosmetics.MojangCape import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.SwatchArt import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape import org.polyfrost.polyplus.client.gui.ppShapeOf @@ -59,15 +79,21 @@ internal fun WardrobeGrid( ownedIds: Set, cartKeys: Set, selection: WardrobeSelection?, + variantPicks: Map, storeVariantPicks: Map, mojangBusy: Boolean, modifier: Modifier, coverFor: (CosmeticUiItem) -> Int?, onOwned: (CosmeticUiItem) -> Unit, + onPreviewOwned: (CosmeticUiItem) -> Unit, + onSelectVariant: (Int, Int) -> Unit, onShop: (CosmeticStoreInfo) -> Unit, + onPickStoreVariant: (Int, Int) -> Unit, onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, onMojang: (MojangCape) -> Unit, onBundle: (BundleInfo) -> Unit, + onBuyBundle: (BundleInfo) -> Unit, onOpenStore: () -> Unit, ) { LazyVerticalGrid( @@ -92,9 +118,11 @@ internal fun WardrobeGrid( onAction = onOpenStore, ) is WardrobeEntry.Note -> CapeSectionNote(entry.text) + is WardrobeEntry.Banner -> WardrobeBanner(entry.text, entry.action, onAction = { OneConfigUI.open(KeybindsGraph) }) WardrobeEntry.ForceCapes -> ForceCapesControl() is WardrobeEntry.Owned -> { val item = entry.item + val variantId = selectedVariantId(item, variantPicks) WardrobeCard( name = item.name, detail = { CardDetailText(item.collection) }, @@ -103,12 +131,23 @@ internal fun WardrobeGrid( equipped = item.equipped, selected = selection == WardrobeSelection.Owned(item.groupId, item.type), onClick = { onOwned(item) }, + variants = item.variants, + selectedVariantId = variantId, + onPickVariant = { onSelectVariant(item.groupId, it) }, + menu = listOf( + CardMenuAction("Preview") { onPreviewOwned(item) }, + CardMenuAction(if (item.equipped) "Unequip" else "Equip") { onOwned(item) }, + ), ) { modifier -> - val (source, loadTick) = rememberCosmeticPreviewSource(item) + val (source, loadTick) = if (variantId != null) { + rememberCosmeticPreviewSource(variantId, item.type) + } else { + null to 0 + } CosmeticThumbnail( source, item.type, - "card-${item.groupId}-$loadTick", + "card-${item.groupId}-$variantId-$loadTick", modifier, checker = false, coverAssetId = coverFor(item), @@ -120,7 +159,8 @@ internal fun WardrobeGrid( val variant = selectedStoreVariant(info, storeVariantPicks, ownedIds) val owned = info.fullyOwned(ownedIds) val inCart = cosmeticCartKey(variant.id) in cartKeys - val variantCount = info.uiVariants().size + val variants = info.uiVariants() + val variantCount = variants.size WardrobeCard( name = info.name, detail = { @@ -132,7 +172,7 @@ internal fun WardrobeGrid( } }, action = when { - owned -> CardAction("Owned", "tick", enabled = false) + owned || variant.id in ownedIds -> CardAction("Owned", "tick", enabled = false) inCart -> CardAction("In cart", "tick", enabled = false) else -> CardAction("Add to cart", "plus") }, @@ -146,6 +186,14 @@ internal fun WardrobeGrid( else -> null }, onClick = { onShop(info) }, + variants = variants, + selectedVariantId = variant.id, + onPickVariant = { onPickStoreVariant(info.id, it) }, + menu = listOf( + CardMenuAction("Preview") { onShop(info) }, + CardMenuAction("Add to cart", enabled = !inCart && !owned && variant.id !in ownedIds) { onAddToCart(info, variant) }, + CardMenuAction("Buy now", enabled = !owned) { onBuyNow(info, variant) }, + ), ) { modifier -> val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) CosmeticThumbnail( @@ -169,6 +217,9 @@ internal fun WardrobeGrid( selected = selection == WardrobeSelection.Mojang(cape.id), enabled = !mojangBusy, onClick = { onMojang(cape) }, + menu = listOf( + CardMenuAction(if (cape.active) "Remove" else "Equip") { onMojang(cape) }, + ), ) { modifier -> val textureVersion = MojangCapeService.textureVersion val texture = remember(cape.textureHash, textureVersion) { MojangCapeService.texture(cape) } @@ -203,6 +254,10 @@ internal fun WardrobeGrid( CardBadge("BUNDLE", CardBadgeStyle.Accent) }, onClick = { onBundle(bundle) }, + menu = listOf( + CardMenuAction("View bundle") { onBundle(bundle) }, + CardMenuAction("Buy now") { onBuyBundle(bundle) }, + ), ) { modifier -> CoverThumbnail(bundle.assetId, modifier) } @@ -228,6 +283,8 @@ private fun WardrobeDivider(label: String) { private data class CardAction(val label: String, val icon: String, val enabled: Boolean = true) +private class CardMenuAction(val label: String, val enabled: Boolean = true, val onSelect: () -> Unit) + private enum class CardBadgeStyle { New, Sale, Accent } private data class CardBadge(val label: String, val style: CardBadgeStyle) @@ -242,6 +299,8 @@ private val CARD_HAIRLINE = Color(0x1AFFFFFF) private val CARD_STRIP = Color(0xB3232D32) private val CARD_HIGHLIGHT = Color(0x0DFFFFFF) +private const val MAX_VISIBLE_SWATCHES = 4 + @Composable private fun CardDetailText(text: String) { CardLabel(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.fillMaxWidth()) @@ -258,11 +317,16 @@ private fun WardrobeCard( onClick: () -> Unit, badge: CardBadge? = null, enabled: Boolean = true, + variants: List = emptyList(), + selectedVariantId: Int? = null, + onPickVariant: (Int) -> Unit = {}, + menu: List = emptyList(), thumbnail: @Composable (Modifier) -> Unit, ) { val theme = LocalTheme.current val interaction = remember { MutableInteractionSource() } val hovered by interaction.collectIsHoveredAsState() + var menuOpen by remember { mutableStateOf(false) } val outline = when { equipped -> Accent selected || (hovered && enabled) -> theme.textColorSecondary @@ -295,6 +359,14 @@ private fun WardrobeCard( .border(1.dp, CARD_HAIRLINE, ppShape(5.dp)), ) { thumbnail(Modifier.fillMaxSize()) + if (hovered && variants.size > 1) { + VariantSwatches( + variants = variants, + selectedVariantId = selectedVariantId, + onPick = onPickVariant, + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + ) + } } } Spacer(Modifier.height(8.dp)) @@ -302,7 +374,22 @@ private fun WardrobeCard( modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - CardLabel(name, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.fillMaxWidth()) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + CardLabel(name, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + if (menu.isNotEmpty() && (hovered || menuOpen)) { + Box( + modifier = Modifier.padding(start = 6.dp).size(width = 22.dp, height = 0.dp), + contentAlignment = Alignment.Center, + ) { + CardMenuButton( + actions = menu, + enabled = enabled, + open = menuOpen, + onOpenChange = { menuOpen = it }, + ) + } + } + } detail() } Spacer(Modifier.height(12.dp)) @@ -314,6 +401,171 @@ private fun WardrobeCard( } } +@Composable +private fun VariantSwatches( + variants: List, + selectedVariantId: Int?, + onPick: (Int) -> Unit, + modifier: Modifier, +) { + val theme = LocalTheme.current + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + variants.take(MAX_VISIBLE_SWATCHES).forEach { variant -> + VariantSwatch(variant, selected = variant.id == selectedVariantId, onClick = { onPick(variant.id) }) + } + val hidden = variants.size - MAX_VISIBLE_SWATCHES + if (hidden > 0) { + Box( + modifier = Modifier.height(16.dp).widthIn(min = 16.dp) + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .border(1.dp, CARD_HAIRLINE, ppShape(5.dp)) + .padding(horizontal = 2.dp), + contentAlignment = Alignment.Center, + ) { + SocialText("+$hidden", color = theme.textColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +private fun VariantSwatch(variant: CosmeticVariantUi, selected: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val swatchVersion = CosmeticSwatches.version + val art = remember(variant.id, swatchVersion) { CosmeticSwatches.get(variant.id) } + Box( + modifier = Modifier.size(width = 14.dp, height = 22.dp) + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .hoverable(interaction) + .clickable(onClick = onClick), + ) { + when (art) { + is SwatchArt.Picture -> Image( + bitmap = art.bitmap, + contentDescription = variant.name, + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize(), + ) + is SwatchArt.Stripes -> Column(Modifier.fillMaxSize()) { + for (color in art.colors) { + Box(Modifier.fillMaxWidth().weight(1f).background(color)) + } + } + null -> Unit + } + Box( + Modifier.fillMaxSize().border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> CARD_HAIRLINE + }, + ppShape(5.dp), + ), + ) + if (hovered) { + TooltipPopup(variant.name) + } + } +} + +@Composable +private fun CardMenuButton( + actions: List, + enabled: Boolean, + open: Boolean, + onOpenChange: (Boolean) -> Unit, +) { + val theme = LocalTheme.current + Box( + modifier = Modifier.requiredSize(22.dp) + .clip(ppShape(6.dp)) + .background(theme.chipBackground) + .clickable { onOpenChange(!open) }, + contentAlignment = Alignment.Center, + ) { + Icon("dots-vertical", color = theme.textColor, modifier = Modifier.size(14.dp)) + if (open) { + CardMenuPopup(actions, enabled = enabled, onDismiss = { onOpenChange(false) }) + } + } +} + +@Composable +private fun CardMenuPopup(actions: List, enabled: Boolean, onDismiss: () -> Unit) { + val theme = LocalTheme.current + val gap = with(LocalDensity.current) { 4.dp.roundToPx() } + val positionProvider = remember(gap) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = (anchorBounds.right - popupContentSize.width) + .coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val below = anchorBounds.bottom + gap + val y = if (below + popupContentSize.height <= windowSize.height) { + below + } else { + (anchorBounds.top - gap - popupContentSize.height).coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier.widthIn(min = 160.dp).width(IntrinsicSize.Max) + .clip(ppShape(16.dp)) + .background(theme.popupBackground) + .border(1.dp, theme.borderColor, ppShape(16.dp)) + .padding(6.dp), + ) { + actions.forEach { action -> + CardMenuRow(action.label, enabled = enabled && action.enabled) { + onDismiss() + action.onSelect() + } + } + } + } +} + +@Composable +private fun CardMenuRow(label: String, enabled: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier.fillMaxWidth().height(32.dp) + .alpha(if (enabled) 1f else 0.4f) + .clip(ppShape(8.dp)) + .background(if (hovered && enabled) theme.chipBackground else Color.Transparent) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + SocialText(label, color = theme.textColor, fontSize = 12.sp, maxLines = 1) + } +} + @Composable private fun CardActionStrip(action: CardAction, active: Boolean, enabled: Boolean, onAction: () -> Unit) { val theme = LocalTheme.current @@ -365,3 +617,17 @@ private fun CardBadgeTab(badge: CardBadge, modifier: Modifier) { ) } } + +@Composable +private fun WardrobeBanner(text: String, action: String, onAction: () -> Unit) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().wardrobePanel().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon("alert-circle", color = theme.textColor, modifier = Modifier.size(16.dp)) + SocialText(text, color = theme.textColor, fontSize = 12.sp, modifier = Modifier.weight(1f)) + SmallButton(action, iconPath = "keyboard", primary = false, onClick = onAction) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt index d5bc6292..130d145b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -101,6 +101,7 @@ internal enum class WardrobeSection(val label: String, val icon: String) { } internal enum class WardrobeSort(val label: String) { + Popular("Popular"), Newest("Newest"), PriceLow("Lowest price"), PriceHigh("Highest price"), @@ -126,6 +127,11 @@ internal sealed interface WardrobeEntry { override val fullSpan: Boolean get() = true } + data class Banner(val id: String, val text: String, val action: String) : WardrobeEntry { + override val key: String get() = "banner-$id" + override val fullSpan: Boolean get() = true + } + data object ForceCapes : WardrobeEntry { override val key: String get() = "force-capes" override val fullSpan: Boolean get() = true @@ -161,6 +167,7 @@ internal val WARDROBE_COSMETIC_TYPES: List = internal fun CosmeticStoreInfo.anyOwned(ownedIds: Set): Boolean = variantList.any { it.id in ownedIds } internal fun List.sortedFor(sort: WardrobeSort): List = when (sort) { + WardrobeSort.Popular -> this WardrobeSort.Newest -> sortedByDescending { it.createdAt } WardrobeSort.PriceLow -> sortedBy { it.finalPrice ?: 0f } WardrobeSort.PriceHigh -> sortedByDescending { it.finalPrice ?: 0f } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt index 1e0ab502..8a3397a9 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +50,7 @@ import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape import org.polyfrost.polyplus.client.gui.preview.PlayerPreview import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation import org.polyfrost.polyplus.client.network.http.responses.BodySlot @Composable @@ -61,7 +61,7 @@ internal fun PreviewPanel( heading: Pair? = null, selectedVariantId: Int? = null, onSelectVariant: (Int) -> Unit = {}, - showBack: Boolean = false, + rotation: PreviewRotation? = null, showAuraColor: Boolean = false, auraColor: Int? = null, onPreviewAuraColor: (Int) -> Unit = {}, @@ -74,16 +74,15 @@ internal fun PreviewPanel( ) { val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped .containsKey(BodySlot.Hat) - key(showBack) { - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = PlayerPreviewSource.LocalLive, - autoSpin = false, - verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, - initialYaw = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG, - live = true, - ) - } + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = PlayerPreviewSource.LocalLive, + autoSpin = false, + verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, + initialYaw = FRONT_YAW_DEG, + live = true, + rotation = rotation, + ) Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { val (title, subtitle) = heading ?: selected?.let { it.name to it.collection } ?: (null to null) if (title != null) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index 511f0f42..990b5c3a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -1,5 +1,7 @@ package org.polyfrost.polyplus.client.gui.wardrobe +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,21 +17,23 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.MojangCapeService -import org.polyfrost.polyplus.client.emotes.EmoteWheels import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.emotes.EmoteWheels +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation import org.polyfrost.polyplus.client.network.http.responses.BundleInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticType @@ -64,19 +68,29 @@ internal fun WardrobeShell( val sections = if (polyPlusAvailable) WardrobeSection.entries else listOf(WardrobeSection.Cosmetics) var section by remember { mutableStateOf(sections.first()) } val query = ShellState.searchQuery - var sort by remember { mutableStateOf(WardrobeSort.Newest) } + var sort by remember { mutableStateOf(WardrobeSort.Popular) } var selection by remember { mutableStateOf(null) } var showCart by remember { mutableStateOf(false) } var showHistory by remember { mutableStateOf(false) } var wheelMode by remember { mutableStateOf(false) } var wheelSlot by remember { mutableStateOf(null) } + var returnPoint by remember { mutableStateOf(null) } + var pendingScroll by remember { mutableStateOf?>(null) } val storeVariantPicks = remember { mutableStateMapOf() } val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() val mojangState = MojangCapeService.state - val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort) { - buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable) + val emoteKeybindWarning = remember(section) { + when { + section != WardrobeSection.Emotes || !polyPlusAvailable -> null + !EmoteWheelKeybind.isBound() -> "The emote wheel has no keybind yet, so it can't be opened in game." + else -> EmoteWheelKeybind.conflictingKeybindNames().takeIf { it.isNotEmpty() } + ?.let { "The emote wheel keybind is also used by ${it.joinToString()}." } + } + } + val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort, emoteKeybindWarning) { + buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable, emoteKeybindWarning) } val anchors = remember(entries) { entries.mapIndexedNotNull { index, entry -> @@ -84,7 +98,9 @@ internal fun WardrobeShell( } } LaunchedEffect(section) { - gridState.scrollToItem(0) + val target = pendingScroll + pendingScroll = null + if (target != null) gridState.scrollToItem(target.first, target.second) else gridState.scrollToItem(0) } val selectedOwned = (selection as? WardrobeSelection.Owned)?.let { sel -> items.firstOrNull { it.groupId == sel.groupId && it.type == sel.type } } @@ -93,6 +109,19 @@ internal fun WardrobeShell( (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == sel.capeId } } val selectedBundle = (selection as? WardrobeSelection.Bundle)?.let { sel -> store.bundles.firstOrNull { it.id == sel.bundleId } } + val previewRotation = remember { PreviewRotation(FRONT_YAW_DEG) } + val showBack = selectedMojang != null || + selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || + selectedShop?.type?.let { it in BACK_VIEW_TYPES } == true || + (selection == null && section == WardrobeSection.Cosmetics) + LaunchedEffect(showBack) { + if (previewRotation.userRotated) return@LaunchedEffect + val target = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG + val delta = ((target - previewRotation.yaw) % 360f + 540f) % 360f - 180f + animate(previewRotation.yaw, previewRotation.yaw + delta, animationSpec = tween(300)) { value, _ -> + if (!previewRotation.userRotated) previewRotation.yaw = value + } + } Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { WardrobeRail( @@ -104,7 +133,7 @@ internal fun WardrobeShell( section = it showHistory = false }, - onAnchor = { index -> scope.launch { gridState.animateScrollToItem(index) } }, + onAnchor = { index -> scope.launch { gridState.scrollToItem(index) } }, ) Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -131,6 +160,7 @@ internal fun WardrobeShell( ownedIds = ownedIds, cartKeys = cart.map { it.key }.toSet(), selection = selection, + variantPicks = variantPicks, storeVariantPicks = storeVariantPicks, mojangBusy = MojangCapeService.busy, modifier = Modifier.weight(1f).fillMaxWidth(), @@ -161,12 +191,19 @@ internal fun WardrobeShell( onEquip(item) } }, + onPreviewOwned = { item -> + selection = WardrobeSelection.Owned(item.groupId, item.type) + showCart = false + }, + onSelectVariant = onSelectVariant, onShop = { info -> val ownedItem = items.firstOrNull { item -> item.owned && info.variantList.any { v -> item.variants.any { it.id == v.id } } } selection = if (ownedItem != null) WardrobeSelection.Owned(ownedItem.groupId, ownedItem.type) else WardrobeSelection.Shop(info.id) showCart = false }, + onPickStoreVariant = { infoId, variantId -> storeVariantPicks[infoId] = variantId }, onAddToCart = onAddToCart, + onBuyNow = onBuyNow, onMojang = { cape -> selection = WardrobeSelection.Mojang(cape.id) showCart = false @@ -176,6 +213,7 @@ internal fun WardrobeShell( selection = WardrobeSelection.Bundle(bundle.id) showCart = false }, + onBuyBundle = onBuyBundle, onOpenStore = { section = WardrobeSection.Featured }, ) } @@ -186,12 +224,31 @@ internal fun WardrobeShell( PreviewModeToggle( wheelMode = wheelMode, onChange = { wheel -> + if (wheel == wheelMode) return@PreviewModeToggle wheelMode = wheel wheelSlot = null if (wheel) { + returnPoint = WardrobeReturnPoint( + section = section, + showHistory = showHistory, + scrollIndex = gridState.firstVisibleItemIndex, + scrollOffset = gridState.firstVisibleItemScrollOffset, + ) section = WardrobeSection.Emotes showHistory = false showCart = false + } else { + val back = returnPoint + returnPoint = null + if (back != null && section == WardrobeSection.Emotes) { + showHistory = back.showHistory + if (back.section == section) { + scope.launch { gridState.scrollToItem(back.scrollIndex, back.scrollOffset) } + } else { + pendingScroll = back.scrollIndex to back.scrollOffset + section = back.section + } + } } }, ) @@ -205,6 +262,12 @@ internal fun WardrobeShell( onRemove = onRemoveFromCart, onCheckout = onCheckout, ) + wheelMode -> EmoteWheelEditor( + selectedSlot = wheelSlot, + coverForEmote = { id -> store.all.firstOrNull { info -> info.variantList.any { it.id == id } }?.coverAssetId }, + onSelectSlot = { wheelSlot = it }, + modifier = Modifier.fillMaxSize(), + ) selectedShop != null -> { val variant = selectedStoreVariant(selectedShop, storeVariantPicks, ownedIds) StoreDetailPanel( @@ -217,14 +280,9 @@ internal fun WardrobeShell( onAddToCart = onAddToCart, onBuyNow = onBuyNow, modifier = Modifier.fillMaxSize(), + rotation = previewRotation, ) } - wheelMode -> EmoteWheelEditor( - selectedSlot = wheelSlot, - coverForEmote = { id -> store.all.firstOrNull { info -> info.variantList.any { it.id == id } }?.coverAssetId }, - onSelectSlot = { wheelSlot = it }, - modifier = Modifier.fillMaxSize(), - ) selectedBundle != null -> BundleDetailPanel( bundle = selectedBundle, status = status, @@ -232,15 +290,13 @@ internal fun WardrobeShell( modifier = Modifier.fillMaxSize(), ) else -> { - val showBack = selectedMojang != null || selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || - (selection == null && section == WardrobeSection.Cosmetics) PreviewPanel( selected = selectedOwned?.takeIf { selectedMojang == null }, heading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION }, status = status, selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, - showBack = showBack, + rotation = previewRotation, showAuraColor = selectedOwned?.type == CosmeticType.Aura, auraColor = auraColor, onPreviewAuraColor = onPreviewAuraColor, @@ -279,3 +335,10 @@ internal fun MinecraftCapesOnlyScreen() { polyPlusAvailable = false, ) } + +private data class WardrobeReturnPoint( + val section: WardrobeSection, + val showHistory: Boolean, + val scrollIndex: Int, + val scrollOffset: Int, +) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt index 98d9445d..378c3367 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -33,6 +33,7 @@ internal class WardrobeStoreData { async { type to CosmeticStore.search( perPage = CosmeticStore.MAX_PAGE_SIZE, + sort = CosmeticStore.Sort.Popularity, types = listOf(type.serializedName), reportFailures = false, ).getOrNull() @@ -64,6 +65,7 @@ internal fun buildWardrobeEntries( query: String, sort: WardrobeSort, polyPlusAvailable: Boolean, + emoteKeybindWarning: String? = null, ): List { val needle = query.trim() fun matches(name: String) = needle.isEmpty() || name.contains(needle, ignoreCase = true) @@ -109,6 +111,9 @@ internal fun buildWardrobeEntries( entries += WardrobeEntry.Note("skins", "The skin library isn't available yet.") WardrobeSection.Emotes -> { + if (emoteKeybindWarning != null) { + entries += WardrobeEntry.Banner("emote-keybind", emoteKeybindWarning, "View keybinds") + } val ownedEmotes = owned(CosmeticType.Emote) if (ownedEmotes.isNotEmpty()) { entries += WardrobeEntry.Divider("Your emotes", anchor = true) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt index c91f412b..3c97e532 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt @@ -35,6 +35,7 @@ import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape import org.polyfrost.polyplus.client.gui.preview.PlayerPreview import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation import org.polyfrost.polyplus.client.network.http.responses.BundleInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticType @@ -146,6 +147,7 @@ internal fun StoreDetailPanel( onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, modifier: Modifier, + rotation: PreviewRotation? = null, ) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { Box( @@ -172,6 +174,7 @@ internal fun StoreDetailPanel( initialYaw = FRONT_YAW_DEG, previewKey = "store-detail-${variant.id}", live = true, + rotation = rotation, ) OwnershipBadge(variant.id in ownedIds, info.createdAt, Modifier.padding(12.dp)) } From fb041a734dd1fc7d943e90902f04ced1d84e353e Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:45 +0200 Subject: [PATCH 11/22] fix: fall back to the offline UUID when the session has none --- .../client/gui/preview/PlayerPreviewRenderer.kt | 3 ++- .../polyplus/client/utils/ClientPlatform.kt | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index 6e16ad1c..c001d305 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -1688,7 +1688,8 @@ object PlayerPreviewRenderer { if (type == MinecraftProfileTexture.Type.SKIN) fetchedSkin = location to (texture.getMetadata("model") == "slim") }, false) } - return DefaultSkinUtils.getDefaultSkin(profile.id) to (DefaultSkinUtils.getDefaultModelType(profile.id) == "slim") + val id = ClientPlatform.localPlayerUuid() + return DefaultSkinUtils.getDefaultSkin(id) to (DefaultSkinUtils.getDefaultModelType(id) == "slim") } private fun readback(fbo: RenderTarget, w: Int, h: Int): ImageBitmap { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt b/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt index c75d8be5..282b37a2 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt @@ -90,7 +90,20 @@ object ClientPlatform { Multithreading.submit { DesktopHelper.browse(URI(uri)) } } - fun localPlayerUuid(): UUID = Minecraft.getInstance().user.profileId + @Volatile + private var offlineUuid: Pair? = null + + fun localPlayerUuid(): UUID { + val user = Minecraft.getInstance().user + val id: UUID? = user.profileId + return id ?: offlineUuidFor(user.name) + } + + private fun offlineUuidFor(name: String): UUID { + offlineUuid?.let { (cachedName, uuid) -> if (cachedName == name) return uuid } + return UUID.nameUUIDFromBytes("OfflinePlayer:$name".toByteArray(Charsets.UTF_8)) + .also { offlineUuid = name to it } + } fun localPlayerName(): String = Minecraft.getInstance().user.name From 83456e9c31a1623e4be1846f2ac9fdd4cb3dc388 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:46 +0200 Subject: [PATCH 12/22] feat: add outfit and skin libraries backed by the PolyPlus API The current Mojang skin is recorded automatically after sign-in, when the wardrobe opens and after every skin change. --- .../client/cosmetics/CosmeticService.kt | 2 + .../client/cosmetics/MojangCapeService.kt | 6 +- .../client/cosmetics/MojangSkinService.kt | 242 +++++++++++ .../client/cosmetics/outfits/OutfitApi.kt | 59 +++ .../client/cosmetics/outfits/OutfitLibrary.kt | 258 ++++++++++++ .../client/cosmetics/outfits/OutfitModels.kt | 113 ++++++ .../client/cosmetics/outfits/SkinLibrary.kt | 383 ++++++++++++++++++ .../client/network/http/PolyAuthorization.kt | 2 + 8 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt index 9a708874..ad4e08a4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt @@ -1,6 +1,7 @@ package org.polyfrost.polyplus.client.cosmetics import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary import org.polyfrost.polyplus.client.network.http.responses.BodySlot import org.polyfrost.polyplus.client.network.http.responses.CosmeticType import org.polyfrost.polyplus.client.network.http.responses.PartialEquippedCosmetics @@ -26,6 +27,7 @@ object CosmeticService { } } PolyConnection.sendPacket(ServerboundPacket.SetParticleColor(color)).getOrThrow() + OutfitLibrary.onParticleColorChanged(color) } suspend fun clearEmote(): Result = runCatching { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt index 581aed44..bc8dba7b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt @@ -31,6 +31,7 @@ import org.apache.logging.log4j.LogManager import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.assets.RemoteTextures +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.runSuspendCatching import java.nio.file.Files @@ -126,7 +127,10 @@ object MojangCapeService { requestLock.withLock { busy = true try { - request(call).onSuccess { changedThisSession = true } + request(call).onSuccess { capes -> + changedThisSession = true + OutfitLibrary.onMojangCapeChanged(capes.firstOrNull { it.active }?.id) + } } finally { busy = false } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt new file mode 100644 index 00000000..a6c7ecfc --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt @@ -0,0 +1,242 @@ +package org.polyfrost.polyplus.client.cosmetics + +import io.ktor.client.call.body +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import net.minecraft.client.Minecraft +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.UUID + +data class MojangSkin(val url: String, val model: SkinModel) { + val textureHash: String? get() = MojangSkinService.textureHash(url) +} + +class MojangSkinException(message: String) : Exception(message) + +object MojangSkinService { + private val LOGGER = LogManager.getLogger("polyplus/mojang-skins") + + private const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" + private const val SKINS_URL = "$PROFILE_URL/skins" + private const val SESSION_PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile" + private const val USERNAME_URL = "https://api.mojang.com/users/profiles/minecraft" + private const val SIGNED_OUT_MESSAGE = "Sign in with a Microsoft account to change your Minecraft skin." + private const val UNREACHABLE_MESSAGE = "Couldn't reach Minecraft's servers." + private const val READBACK_ATTEMPTS = 5 + private const val READBACK_DELAY_MS = 1_000L + private val TEXTURE_HASH = Regex("^[0-9a-f]{32,64}$") + + private val requestLock = Mutex() + + @Volatile + private var knownTexture: String? = null + + suspend fun setSkin(url: String, model: SkinModel): Result = change { token -> + PolyPlusClient.HTTP.post(SKINS_URL) { + authorize(token) + contentType(ContentType.Application.Json) + setBody( + buildJsonObject { + put("variant", model.serializedName) + put("url", url) + }, + ) + } + } + + suspend fun uploadSkin(png: ByteArray, model: SkinModel): Result = change { token -> + PolyPlusClient.HTTP.post(SKINS_URL) { + authorize(token) + setBody( + MultiPartFormDataContent( + formData { + append("variant", model.serializedName) + append( + "file", + png, + Headers.build { + append(HttpHeaders.ContentType, "image/png") + append(HttpHeaders.ContentDisposition, "filename=\"skin.png\"") + }, + ) + }, + ), + ) + } + } + + suspend fun activeSkin(): Result = runSuspendCatching { + val token = accessToken() ?: return@runSuspendCatching null + readProfile(token) + } + + suspend fun profileSkin(uuid: UUID): Result = runSuspendCatching { + val id = uuid.toString().replace("-", "") + val response = PolyPlusClient.HTTP.get("$SESSION_PROFILE_URL/$id") { + accept(ContentType.Application.Json) + } + if (response.status != HttpStatusCode.OK) return@runSuspendCatching null + val texturesValue = response.body().properties + .firstOrNull { it.name == "textures" } + ?.value + ?: return@runSuspendCatching null + val decoded = String(Base64.getDecoder().decode(texturesValue), StandardCharsets.UTF_8) + val skin = PolyPlusClient.JSON.decodeFromString(TexturesPayload.serializer(), decoded).textures["SKIN"] + ?: return@runSuspendCatching null + if (skin.url.isBlank()) return@runSuspendCatching null + MojangSkin(secure(skin.url), SkinModel.fromSerialized(skin.metadata["model"])) + } + + suspend fun uuidForName(username: String): Result = runSuspendCatching { + val response = PolyPlusClient.HTTP.get("$USERNAME_URL/$username") { + accept(ContentType.Application.Json) + } + when { + response.status == HttpStatusCode.OK -> parseUndashed(response.body().id) + response.status == HttpStatusCode.NoContent || response.status == HttpStatusCode.NotFound -> null + response.status == HttpStatusCode.TooManyRequests -> + throw MojangSkinException("Minecraft is limiting lookups right now. Try again in a minute.") + else -> throw MojangSkinException(UNREACHABLE_MESSAGE) + } + } + + fun textureHash(url: String): String? = + url.substringBefore('?') + .substringBefore('#') + .trimEnd('/') + .substringAfterLast('/') + .lowercase() + .takeIf { TEXTURE_HASH.matches(it) } + + fun failureMessage(error: Throwable): String = when (error) { + is MojangSkinException -> error.message ?: SIGNED_OUT_MESSAGE + else -> UNREACHABLE_MESSAGE + } + + private suspend fun change(call: suspend (String) -> HttpResponse): Result = requestLock.withLock { + runSuspendCatching { + val token = accessToken() ?: throw MojangSkinException(SIGNED_OUT_MESSAGE) + val previous = knownTexture + val response = call(token) + ensureSkinChanged(response) + val reported = runSuspendCatching { response.body() }.getOrNull()?.let(::activeSkin) + if (reported != null) { + knownTexture = reported.textureHash + reported + } else { + readBack(token, previous) + } + }.onFailure { LOGGER.warn("Minecraft skin change failed", it) } + } + + private suspend fun readBack(token: String, previous: String?): MojangSkin? { + var latest: MojangSkin? = null + repeat(READBACK_ATTEMPTS) { + delay(READBACK_DELAY_MS) + val skin = runSuspendCatching { readProfile(token) }.getOrNull() + if (skin != null) { + latest = skin + if (skin.textureHash != previous) return skin + } + } + return latest + } + + private suspend fun readProfile(token: String): MojangSkin? { + val response = PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } + val status = response.status + if (status == HttpStatusCode.Unauthorized || status == HttpStatusCode.Forbidden || status == HttpStatusCode.NotFound) { + return null + } + if (!status.isSuccess()) throw MojangSkinException(UNREACHABLE_MESSAGE) + return activeSkin(response.body()).also { knownTexture = it?.textureHash } + } + + private fun activeSkin(profile: ServicesProfile): MojangSkin? { + val active = profile.skins + .firstOrNull { it.state.equals("ACTIVE", ignoreCase = true) && it.url.isNotBlank() } + ?: return null + return MojangSkin(secure(active.url), SkinModel.fromSerialized(active.variant)) + } + + private fun ensureSkinChanged(response: HttpResponse) { + val status = response.status + if (status.isSuccess()) return + throw MojangSkinException( + when (status) { + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> SIGNED_OUT_MESSAGE + HttpStatusCode.TooManyRequests -> "Minecraft is limiting skin changes right now. Try again in a minute." + HttpStatusCode.BadRequest -> "Minecraft couldn't use that skin." + else -> "Minecraft's servers couldn't change your skin right now." + }, + ) + } + + private fun parseUndashed(id: String): UUID? = runCatching { + if (id.length != 32) return@runCatching UUID.fromString(id) + UUID.fromString( + "${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}", + ) + }.getOrNull() + + private fun secure(url: String): String = + if (url.startsWith("http://textures.minecraft.net/")) "https://" + url.removePrefix("http://") else url + + private fun HttpRequestBuilder.authorize(token: String) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + } + + private fun accessToken(): String? = runCatching { + //? if > 1.8.9 { + Minecraft.getInstance().user.accessToken + //?} else { + /*Minecraft.getInstance().session.accessToken + *///?} + }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } + + @Serializable + private data class SessionProfile(val properties: List = emptyList()) + + @Serializable + private data class ProfileProperty(val name: String = "", val value: String = "") + + @Serializable + private data class TexturesPayload(val textures: Map = emptyMap()) + + @Serializable + private data class TextureEntry(val url: String = "", val metadata: Map = emptyMap()) + + @Serializable + private data class ServicesProfile(val skins: List = emptyList()) + + @Serializable + private data class ServicesSkin(val state: String = "", val url: String = "", val variant: String = "") + + @Serializable + private data class NameLookup(val id: String = "") +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt new file mode 100644 index 00000000..60df65d9 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt @@ -0,0 +1,59 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.serialization.json.JsonObject +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.PolyPlusConfig +import org.polyfrost.polyplus.client.cosmetics.MojangSkinException +import org.polyfrost.polyplus.client.network.http.requestBodyAuthorized +import org.polyfrost.polyplus.client.privacy.OnlineServicesDisabledException +import org.polyfrost.polyplus.privacy.PrivacyConsent + +internal object OutfitApi { + const val PRIVACY_MESSAGE = + "Outfits need the Terms of Service and Privacy Policy. Accept them under Privacy in the PolyPlus settings." + private const val OFFLINE_MESSAGE = "Couldn't reach the PolyPlus servers." + + fun url(path: String): String = "${PolyPlusConfig.apiUrl}$path" + + fun allowed(): Boolean = PrivacyConsent.allowsOnlineServices() + + fun privacyFailure(): Result = Result.failure(OutfitException(PRIVACY_MESSAGE)) + + suspend inline fun send( + method: HttpMethod, + path: String, + body: JsonObject? = null, + ): Result = PolyPlusClient.HTTP.requestBodyAuthorized(url(path), method) { + if (body != null) { + contentType(ContentType.Application.Json) + setBody(body) + } + } + + fun failureMessage(error: Throwable, notFound: String, conflict: String, badRequest: String): String = when (error) { + is OutfitException -> error.message ?: OFFLINE_MESSAGE + is MojangSkinException -> error.message ?: OFFLINE_MESSAGE + is OnlineServicesDisabledException -> PRIVACY_MESSAGE + is ClientRequestException -> when (error.response.status) { + HttpStatusCode.NotFound -> notFound + HttpStatusCode.Conflict -> conflict + HttpStatusCode.BadRequest -> backendText(error) ?: badRequest + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> + "Couldn't sign in to PolyPlus. Try again in a moment." + HttpStatusCode.TooManyRequests -> "Too many changes at once. Try again in a moment." + else -> backendText(error) ?: OFFLINE_MESSAGE + } + is ServerResponseException -> "The PolyPlus servers are having trouble. Try again later." + else -> OFFLINE_MESSAGE + } + + private fun backendText(error: ClientRequestException): String? = + error.message.substringAfter("Text: \"", "").substringBeforeLast('"').trim().takeIf { it.isNotBlank() } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt new file mode 100644 index 00000000..a433e5b4 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -0,0 +1,258 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.ktor.http.HttpMethod +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.MojangSkinService +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.util.UUID + +object OutfitLibrary { + private val LOGGER = LogManager.getLogger("polyplus/outfits") + + private val requestLock = Mutex() + + var outfits by mutableStateOf>(emptyList()) + private set + + var activeOutfitId by mutableStateOf(null) + private set + + var state by mutableStateOf(LibraryState.Idle) + private set + + var limit by mutableIntStateOf(LIBRARY_LIMIT) + private set + + var busy by mutableStateOf(false) + private set + + @Volatile + private var applying = false + + @Volatile + private var loadedFor: UUID? = null + + suspend fun refreshIfNeeded() { + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (stale || state is LibraryState.Idle || state is LibraryState.Unavailable) refresh() + SkinLibrary.recordInBackground() + } + + suspend fun refresh(): Result = requestLock.withLock { refreshLocked() } + + suspend fun createFromCurrent(): Result { + if (!OutfitApi.allowed()) return unavailable() + return requestLock.withLock { + busy = true + try { + val skinId = SkinLibrary.recordCurrent() + .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } + .getOrNull()?.id + MojangCapeService.refreshIfNeeded() + val capeId = MojangCapeService.activeCape()?.id + val particleColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) + val body = buildJsonObject { + put("skin_id", skinId) + put("mojang_cape_id", capeId) + put("particle_color", particleColor) + } + OutfitApi.send(HttpMethod.Post, "/outfits", body) + .map { it.toInfo() } + .onSuccess { created -> + outfits = listOf(created) + outfits.filter { it.id != created.id } + activeOutfitId = created.id + refreshLocked() + } + .onFailure { LOGGER.error("Failed to create an outfit", it) } + } finally { + busy = false + } + } + } + + suspend fun apply(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return requestLock.withLock { + busy = true + applying = true + try { + applyLocked(id) + } finally { + applying = false + busy = false + } + } + } + + suspend fun rename(id: Int, name: String): Result { + val trimmed = name.trim() + if (trimmed.isEmpty()) return Result.failure(OutfitException("Give the outfit a name.")) + return patch(id, buildJsonObject { put("name", trimmed) }) + } + + suspend fun setFavorite(id: Int, favorite: Boolean): Result = + patch(id, buildJsonObject { put("favorite", favorite) }) + + suspend fun delete(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Delete, "/outfits/$id") + .onSuccess { + outfits = outfits.filter { it.id != id } + if (activeOutfitId == id) activeOutfitId = null + } + .onFailure { LOGGER.error("Failed to delete outfit {}", id, it) } + } + + fun failureMessage(error: Throwable): String = OutfitApi.failureMessage( + error, + notFound = "That outfit no longer exists.", + conflict = "You've reached the limit of $limit outfits. Delete one to make room.", + badRequest = "PolyPlus couldn't save that outfit.", + ) + + internal fun activeOutfit(): OutfitInfo? = activeOutfitId?.let { id -> outfits.firstOrNull { it.id == id } } + + internal fun onMojangCapeChanged(capeId: String?) { + val outfit = liveSyncTarget() ?: return + if (outfit.mojangCapeId == capeId) return + PolyPlusClient.SCOPE.launch { patch(outfit.id, buildJsonObject { put("mojang_cape_id", capeId) }) } + } + + internal fun onParticleColorChanged(color: Int?) { + val outfit = liveSyncTarget() ?: return + if (outfit.particleColor == color) return + PolyPlusClient.SCOPE.launch { patch(outfit.id, buildJsonObject { put("particle_color", color) }) } + } + + internal suspend fun onSkinWorn(skinId: Int): Result { + val id = activeOutfitId ?: return Result.success(Unit) + if (activeOutfit()?.skinId == skinId) return Result.success(Unit) + return patch(id, buildJsonObject { put("skin_id", skinId) }).map { } + } + + internal fun onSkinDeleted(skinId: Int) { + outfits = outfits.map { if (it.skinId == skinId) it.copy(skinId = null) else it } + } + + private fun liveSyncTarget(): OutfitInfo? { + if (applying || !OutfitApi.allowed()) return null + return activeOutfit() + } + + private suspend fun patch(id: Int, body: JsonObject): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Patch, "/outfits/$id", body) + .map { it.toInfo() } + .onSuccess(::replace) + .onFailure { LOGGER.error("Failed to update outfit {}", id, it) } + } + + private suspend fun refreshLocked(): Result { + if (!OutfitApi.allowed()) { + outfits = emptyList() + activeOutfitId = null + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } + val owner = ClientPlatform.localPlayerUuid() + if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading + return OutfitApi.send(HttpMethod.Get, "/outfits") + .onSuccess { response -> + outfits = response.outfits.map { it.toInfo() } + activeOutfitId = response.activeOutfitId + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + } + .onFailure { error -> + LOGGER.error("Failed to fetch outfits", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } + + private suspend fun applyLocked(id: Int): Result { + val response = OutfitApi.send(HttpMethod.Post, "/outfits/$id/apply") + .onFailure { LOGGER.error("Failed to apply outfit {}", id, it) } + .getOrElse { return Result.failure(it) } + val outfit = response.outfit.toInfo() + replace(outfit) + activeOutfitId = outfit.id + response.skin?.let { skin -> SkinLibrary.remember(skin.toInfo()) } + + val problems = mutableListOf() + response.skin?.toInfo()?.let { skin -> + val current = MojangSkinService.activeSkin().getOrNull() + if (current == null || current.textureHash != skin.hash || current.model != skin.model) { + MojangSkinService.setSkin(skin.url, skin.model) + .onSuccess { SkinLibrary.recordInBackground(force = true) } + .onFailure { + problems += "your Minecraft skin wasn't changed (${MojangSkinService.failureMessage(it)})" + } + } + } + applyMojangCape(outfit.mojangCapeId).onFailure { + problems += "your Minecraft cape wasn't changed (${MojangCapeService.failureMessage(it)})" + } + + CosmeticService.syncLocalActive().onFailure { LOGGER.warn("Failed to resync cosmetics after applying outfit {}", id, it) } + val uuid = ClientPlatform.localPlayerUuid() + val color = outfit.particleColor + ClientPlatform.runOnMain { + if (color != null) { + CosmeticCatalog.setParticleColor(uuid, color) + } else { + CosmeticCatalog.clearParticleColor(uuid) + } + } + + if (problems.isEmpty()) return Result.success(outfit) + val message = "Outfit applied, but " + problems.joinToString(" and ") + "." + LOGGER.warn("Outfit {} applied with problems: {}", id, message) + return Result.failure(OutfitException(message)) + } + + private suspend fun applyMojangCape(capeId: String?): Result = runSuspendCatching { + MojangCapeService.refreshIfNeeded() + val loaded = MojangCapeService.state is MojangCapeState.Loaded + val current = MojangCapeService.activeCape()?.id + if (capeId == null) { + if (!loaded || current == null) return@runSuspendCatching + MojangCapeService.clear().getOrThrow() + } else { + if (loaded && current == capeId) return@runSuspendCatching + MojangCapeService.select(capeId).getOrThrow() + } + Unit + } + + private fun replace(outfit: OutfitInfo) { + val existing = outfits + outfits = if (existing.any { it.id == outfit.id }) { + existing.map { if (it.id == outfit.id) outfit else it } + } else { + listOf(outfit) + existing + } + } + + private fun unavailable(): Result { + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt new file mode 100644 index 00000000..e6fb33c0 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt @@ -0,0 +1,113 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.polyfrost.polyplus.client.network.http.responses.BodySlot + +enum class SkinModel(val serializedName: String) { + Classic("classic"), + Slim("slim"); + + internal companion object { + fun fromSerialized(value: String?): SkinModel = + if (value.equals(Slim.serializedName, ignoreCase = true)) Slim else Classic + } +} + +data class PlayerSkinInfo( + val id: Int, + val name: String, + val model: SkinModel, + val url: String, + val hash: String, + val favorite: Boolean, + val createdAt: String, +) + +data class OutfitInfo( + val id: Int, + val name: String, + val favorite: Boolean, + val skinId: Int?, + val mojangCapeId: String?, + val particleColor: Int?, + val equipped: Map, + val createdAt: String, + val updatedAt: String, +) + +sealed interface LibraryState { + data object Idle : LibraryState + data object Loading : LibraryState + data object Loaded : LibraryState + data class Unavailable(val reason: String) : LibraryState +} + +class OutfitException(message: String) : Exception(message) + +@Serializable +internal data class SkinResponse( + val id: Int, + val name: String = "", + val model: String = SkinModel.Classic.serializedName, + val url: String = "", + val hash: String = "", + val favorite: Boolean = false, + @SerialName("created_at") val createdAt: String = "", +) { + fun toInfo(): PlayerSkinInfo = PlayerSkinInfo( + id = id, + name = name, + model = SkinModel.fromSerialized(model), + url = url, + hash = hash.lowercase(), + favorite = favorite, + createdAt = createdAt, + ) +} + +@Serializable +internal data class OutfitResponse( + val id: Int, + val name: String = "", + val favorite: Boolean = false, + @SerialName("skin_id") val skinId: Int? = null, + @SerialName("mojang_cape_id") val mojangCapeId: String? = null, + @SerialName("particle_color") val particleColor: Int? = null, + val equipped: Map = emptyMap(), + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) { + fun toInfo(): OutfitInfo = OutfitInfo( + id = id, + name = name, + favorite = favorite, + skinId = skinId, + mojangCapeId = mojangCapeId, + particleColor = particleColor, + equipped = equipped.filterKeys { it != BodySlot.Unknown }, + createdAt = createdAt, + updatedAt = updatedAt, + ) +} + +@Serializable +internal data class OutfitListResponse( + val outfits: List = emptyList(), + @SerialName("active_outfit_id") val activeOutfitId: Int? = null, + val limit: Int = LIBRARY_LIMIT, +) + +@Serializable +internal data class SkinListResponse( + val skins: List = emptyList(), + val limit: Int = LIBRARY_LIMIT, +) + +@Serializable +internal data class ApplyOutfitResponse( + val outfit: OutfitResponse, + val skin: SkinResponse? = null, +) + +internal const val LIBRARY_LIMIT = 128 diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt new file mode 100644 index 00000000..136bf442 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt @@ -0,0 +1,383 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.HttpMethod +import io.ktor.http.contentLength +import io.ktor.http.isSuccess +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangSkin +import org.polyfrost.polyplus.client.cosmetics.MojangSkinException +import org.polyfrost.polyplus.client.cosmetics.MojangSkinService +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.io.ByteArrayInputStream +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference +import javax.imageio.ImageIO + +object SkinLibrary { + private val LOGGER = LogManager.getLogger("polyplus/skins") + + private const val MAX_SKIN_BYTES = 64 * 1024 + private const val MAX_DOWNLOAD_BYTES = 1024L * 1024L + private const val RECORD_INTERVAL_MS = 30_000L + private const val NOT_A_SKIN = "That image isn't a Minecraft skin." + private const val DOWNLOAD_FAILED = "Couldn't download that image." + private val USERNAME_PATTERN = Regex("^[A-Za-z0-9_]{1,16}$") + private val HASH_PATTERN = Regex("^[0-9a-f]{32,64}$") + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + + private val requestLock = Mutex() + private val recordLock = Mutex() + private val recordJobLock = Mutex() + private val cacheDir = File("${PolyPlusConstants.NAME}/skins") + private val bitmaps = ConcurrentHashMap() + private val loading = ConcurrentHashMap.newKeySet() + private val failed = ConcurrentHashMap.newKeySet() + private val signedInFor = AtomicReference(null) + + var skins by mutableStateOf>(emptyList()) + private set + + var state by mutableStateOf(LibraryState.Idle) + private set + + var limit by mutableIntStateOf(LIBRARY_LIMIT) + private set + + var busy by mutableStateOf(false) + private set + + var bitmapVersion by mutableIntStateOf(0) + private set + + @Volatile + private var loadedFor: UUID? = null + + @Volatile + private var recordJob: Deferred>? = null + + @Volatile + private var lastRecordOwner: UUID? = null + + @Volatile + private var lastRecordAt = 0L + + suspend fun refreshIfNeeded() { + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (stale || state is LibraryState.Idle || state is LibraryState.Unavailable) { + refresh() + } else { + recordInBackground() + } + } + + suspend fun refresh(): Result = refreshLocked().also { recordInBackground() } + + suspend fun recordCurrent(): Result = recordShared(fresh = false) + + suspend fun addFromBytes(png: ByteArray, name: String?, model: SkinModel): Result { + if (!OutfitApi.allowed()) return unavailable() + runSuspendCatching { withContext(Dispatchers.IO) { validate(png) } } + .onFailure { return Result.failure(it) } + return uploadAndRecord(name) { MojangSkinService.uploadSkin(png, model) } + } + + suspend fun addFromUrl(url: String, name: String?, model: SkinModel): Result { + if (!OutfitApi.allowed()) return unavailable() + val link = url.trim() + if (!link.startsWith("https://", ignoreCase = true) && !link.startsWith("http://", ignoreCase = true)) { + return Result.failure(OutfitException("Enter a link that starts with https://.")) + } + return uploadAndRecord(name) { MojangSkinService.setSkin(link, model) } + } + + suspend fun addFromUsername(username: String): Result { + if (!OutfitApi.allowed()) return unavailable() + val name = username.trim() + if (!USERNAME_PATTERN.matches(name)) { + return Result.failure(OutfitException("That isn't a valid Minecraft username.")) + } + val uuid = MojangSkinService.uuidForName(name) + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure(OutfitException("No Minecraft account is called $name.")) + val skin = MojangSkinService.profileSkin(uuid) + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure(OutfitException("$name is using a default skin.")) + busy = true + try { + return recordLock.withLock { record(skin, name, ClientPlatform.localPlayerUuid()) } + } finally { + busy = false + } + } + + suspend fun wear(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + val skin = skins.firstOrNull { it.id == id } + ?: return Result.failure(OutfitException("That skin is no longer in your library.")) + busy = true + try { + MojangSkinService.setSkin(skin.url, skin.model) + .getOrElse { return Result.failure(mojangFailure(it)) } + val patched = OutfitLibrary.onSkinWorn(id) + recordInBackground(force = true) + patched.onFailure { + return Result.failure(OutfitException("Your skin changed, but the active outfit couldn't be updated.")) + } + return Result.success(Unit) + } finally { + busy = false + } + } + + suspend fun rename(id: Int, name: String): Result { + val trimmed = name.trim() + if (trimmed.isEmpty()) return Result.failure(OutfitException("Give the skin a name.")) + return patch(id, buildJsonObject { put("name", trimmed) }) + } + + suspend fun setFavorite(id: Int, favorite: Boolean): Result = + patch(id, buildJsonObject { put("favorite", favorite) }) + + suspend fun setModel(id: Int, model: SkinModel): Result = + patch(id, buildJsonObject { put("model", model.serializedName) }) + + suspend fun delete(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Delete, "/skins/$id") + .onSuccess { + skins = skins.filter { it.id != id } + OutfitLibrary.onSkinDeleted(id) + } + .onFailure { LOGGER.error("Failed to delete skin {}", id, it) } + } + + fun wornSkinId(): Int? = OutfitLibrary.activeOutfit()?.skinId + + fun bitmap(skin: PlayerSkinInfo): ImageBitmap? { + val key = skin.hash.ifBlank { skin.url } + bitmaps[key]?.let { return it } + if (key !in failed && loading.add(key)) loadBitmap(skin, key) + return null + } + + fun failureMessage(error: Throwable): String = OutfitApi.failureMessage( + error, + notFound = "That skin no longer exists.", + conflict = "Your skin library is full ($limit skins). Delete one to make room.", + badRequest = NOT_A_SKIN, + ) + + internal fun remember(skin: PlayerSkinInfo) { + if (state !is LibraryState.Loaded) return + replace(skin) + } + + internal fun onSignedIn(profileId: UUID) { + if (signedInFor.getAndSet(profileId) == profileId) return + recordInBackground(force = true) + } + + internal fun recordInBackground(force: Boolean = false) { + if (!OutfitApi.allowed()) return + val owner = runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() ?: return + if (!force && lastRecordOwner == owner && System.currentTimeMillis() - lastRecordAt < RECORD_INTERVAL_MS) return + PolyPlusClient.SCOPE.launch { + recordShared(fresh = force).onFailure { LOGGER.warn("Could not save the current Minecraft skin", it) } + } + } + + private suspend fun recordShared(fresh: Boolean): Result { + if (!OutfitApi.allowed()) return OutfitApi.privacyFailure() + val job = recordJobLock.withLock { + val running = recordJob?.takeIf { it.isActive } + if (running != null && !fresh) { + running + } else { + PolyPlusClient.SCOPE.async { + running?.join() + recordLock.withLock { recordActive() } + }.also { recordJob = it } + } + } + return job.await() + } + + private suspend fun recordActive(): Result { + val owner = ClientPlatform.localPlayerUuid() + try { + val current = MojangSkinService.activeSkin() + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.success(null) + return record(current, null, owner) + } finally { + lastRecordOwner = owner + lastRecordAt = System.currentTimeMillis() + } + } + + private suspend fun uploadAndRecord( + name: String?, + upload: suspend () -> Result, + ): Result { + busy = true + try { + val recorded = recordLock.withLock { + val owner = ClientPlatform.localPlayerUuid() + val worn = upload().getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure( + OutfitException("Your skin changed, but Minecraft hasn't reported it yet. Try again in a moment."), + ) + record(worn, name, owner).also { + lastRecordOwner = owner + lastRecordAt = System.currentTimeMillis() + } + } + recorded.onSuccess { skin -> + OutfitLibrary.onSkinWorn(skin.id) + .onFailure { LOGGER.warn("Could not move the active outfit to skin {}", skin.id, it) } + } + return recorded + } finally { + busy = false + } + } + + private suspend fun record(skin: MojangSkin, name: String?, owner: UUID): Result { + val hash = skin.textureHash + ?: return Result.failure(OutfitException("Minecraft didn't report a usable skin texture.")) + if (name == null && loadedFor == owner) { + skins.firstOrNull { it.hash == hash }?.let { return Result.success(it) } + } + val label = name?.trim()?.takeIf { it.isNotEmpty() } + val body = buildJsonObject { + put("texture_hash", hash) + put("model", skin.model.serializedName) + if (label != null) put("name", label) + } + return OutfitApi.send(HttpMethod.Post, "/skins", body) + .map { it.toInfo() } + .onSuccess { recorded -> if (loadedFor == owner) replace(recorded) } + .onFailure { LOGGER.error("Failed to save skin {}", hash, it) } + } + + private suspend fun refreshLocked(): Result = requestLock.withLock { + failed.clear() + if (!OutfitApi.allowed()) { + skins = emptyList() + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return@withLock OutfitApi.privacyFailure() + } + val owner = ClientPlatform.localPlayerUuid() + if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading + OutfitApi.send(HttpMethod.Get, "/skins") + .onSuccess { response -> + skins = response.skins.map { it.toInfo() } + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + } + .onFailure { error -> + LOGGER.error("Failed to fetch skins", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } + + private suspend fun patch(id: Int, body: JsonObject): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Patch, "/skins/$id", body) + .map { it.toInfo() } + .onSuccess(::replace) + .onFailure { LOGGER.error("Failed to update skin {}", id, it) } + } + + private suspend fun download(url: String): Result = runSuspendCatching { + val response = runSuspendCatching { PolyPlusClient.HTTP.get(url) } + .getOrElse { throw OutfitException(DOWNLOAD_FAILED) } + if (!response.status.isSuccess()) throw OutfitException(DOWNLOAD_FAILED) + val length = response.contentLength() + if (length != null && length > MAX_DOWNLOAD_BYTES) throw OutfitException(NOT_A_SKIN) + runSuspendCatching { response.bodyAsBytes() }.getOrElse { throw OutfitException(DOWNLOAD_FAILED) } + }.onFailure { LOGGER.warn("Failed to download a skin from {}", url, it) } + + private fun validate(png: ByteArray) { + if (png.size < PNG_SIGNATURE.size || !PNG_SIGNATURE.indices.all { png[it] == PNG_SIGNATURE[it] }) { + throw OutfitException("Skins must be PNG images.") + } + val image = runCatching { ImageIO.read(ByteArrayInputStream(png)) }.getOrNull() + ?: throw OutfitException(NOT_A_SKIN) + if (image.width != 64 || (image.height != 64 && image.height != 32)) throw OutfitException(NOT_A_SKIN) + if (png.size > MAX_SKIN_BYTES) throw OutfitException("That skin file is too large. Skins can be at most 64 KB.") + } + + private fun loadBitmap(skin: PlayerSkinInfo, key: String) { + PolyPlusClient.SCOPE.launch { + runSuspendCatching { + val cached = withContext(Dispatchers.IO) { readCache(skin.hash) } + val bytes = cached ?: download(skin.url).getOrThrow().also { fresh -> + withContext(Dispatchers.IO) { writeCache(skin.hash, fresh) } + } + val image = withContext(Dispatchers.IO) { ImageIO.read(ByteArrayInputStream(bytes)) } + ?: throw OutfitException(NOT_A_SKIN) + bitmaps[key] = image.toComposeImageBitmap() + bitmapVersion++ + }.onFailure { + failed.add(key) + LOGGER.debug("Could not load the preview for skin {}", skin.id, it) + } + loading.remove(key) + } + } + + private fun cacheFile(hash: String): File? = + hash.lowercase().takeIf { HASH_PATTERN.matches(it) }?.let { File(cacheDir, "$it.png") } + + private fun readCache(hash: String): ByteArray? = + cacheFile(hash)?.takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() } + + private fun writeCache(hash: String, bytes: ByteArray) { + val file = cacheFile(hash) ?: return + if (file.isFile) return + if (!cacheDir.isDirectory && !cacheDir.mkdirs()) return + file.writeBytes(bytes) + } + + private fun replace(skin: PlayerSkinInfo) { + val existing = skins + skins = if (existing.any { it.id == skin.id }) { + existing.map { if (it.id == skin.id) skin else it } + } else { + listOf(skin) + existing + } + } + + private fun mojangFailure(error: Throwable): Throwable = MojangSkinException(MojangSkinService.failureMessage(error)) + + private fun unavailable(): Result { + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt index 8b2b326d..a07a47e3 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt @@ -19,6 +19,7 @@ import org.polyfrost.polyplus.BackendUrl import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.PolyPlusConfig +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary import org.polyfrost.polyplus.client.host.HostWorldManager import org.polyfrost.polyplus.client.network.http.responses.AuthResponse import org.polyfrost.polyplus.client.privacy.OnlineServicesDisabledException @@ -196,6 +197,7 @@ object PolyAuthorization { if (modsReported.compareAndSet(false, true)) { PolyPlusClient.SCOPE.launch(Dispatchers.IO) { reportMods(response.token) } } + SkinLibrary.onSignedIn(profileId) return Authorized(profileId, response) } From 5ef63bb91de2a859be1c1611510bad5e60576b67 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:46 +0200 Subject: [PATCH 13/22] feat: add outfits, skins and an outfit switcher to the wardrobe --- .../client/gui/wardrobe/WardrobeActions.kt | 115 +++++ .../client/gui/wardrobe/WardrobeCommon.kt | 45 ++ .../client/gui/wardrobe/WardrobeGrid.kt | 198 +++++++- .../client/gui/wardrobe/WardrobeHeader.kt | 4 +- .../client/gui/wardrobe/WardrobeLibrary.kt | 474 ++++++++++++++++++ .../client/gui/wardrobe/WardrobeModels.kt | 21 + .../client/gui/wardrobe/WardrobeShell.kt | 68 ++- .../client/gui/wardrobe/WardrobeStoreData.kt | 58 ++- .../gui/wardrobe/WardrobeWheelEditor.kt | 6 +- 9 files changed, 964 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt index 86dd4223..74e7fad4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt @@ -10,6 +10,11 @@ import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.cosmetics.MojangCape import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel import org.polyfrost.polyplus.client.network.http.responses.CosmeticType import org.polyfrost.polyplus.client.utils.ClientPlatform @@ -29,6 +34,116 @@ internal fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { } } +private fun launchLibraryCall( + onStatus: (String) -> Unit, + pending: String?, + call: suspend () -> Result, + failure: (Throwable) -> String, + success: (T) -> String, +) { + pending?.let(onStatus) + PolyPlusClient.SCOPE.launch { + val result = call() + ClientPlatform.runOnMain { onStatus(result.fold(onSuccess = success, onFailure = failure)) } + } +} + +internal fun wearOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { + if (OutfitLibrary.busy) return + launchLibraryCall( + onStatus, + "Switching to ${outfit.name}...", + { OutfitLibrary.apply(outfit.id) }, + OutfitLibrary::failureMessage, + ) { "Wearing ${it.name}." } +} + +internal fun createOutfitFromCurrent(onStatus: (String) -> Unit) { + if (OutfitLibrary.busy) return + launchLibraryCall( + onStatus, + "Saving your current look...", + { OutfitLibrary.createFromCurrent() }, + OutfitLibrary::failureMessage, + ) { "Saved ${it.name}." } +} + +internal fun renameOutfit(outfit: OutfitInfo, name: String, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + null, + { OutfitLibrary.rename(outfit.id, name) }, + OutfitLibrary::failureMessage, + ) { "Renamed ${outfit.name} to ${it.name}." } +} + +internal fun toggleOutfitFavorite(outfit: OutfitInfo, onStatus: (String) -> Unit) { + val favorite = !outfit.favorite + launchLibraryCall( + onStatus, + null, + { OutfitLibrary.setFavorite(outfit.id, favorite) }, + OutfitLibrary::failureMessage, + ) { if (favorite) "Added ${it.name} to favourites." else "Removed ${it.name} from favourites." } +} + +internal fun deleteOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + "Deleting ${outfit.name}...", + { OutfitLibrary.delete(outfit.id) }, + OutfitLibrary::failureMessage, + ) { "Deleted ${outfit.name}." } +} + +internal fun wearSkin(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + if (SkinLibrary.busy) return + launchLibraryCall( + onStatus, + "Changing your skin to ${skin.name}...", + { SkinLibrary.wear(skin.id) }, + SkinLibrary::failureMessage, + ) { "Wearing ${skin.name}." } +} + +internal fun renameSkin(skin: PlayerSkinInfo, name: String, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + null, + { SkinLibrary.rename(skin.id, name) }, + SkinLibrary::failureMessage, + ) { "Renamed ${skin.name} to ${it.name}." } +} + +internal fun toggleSkinFavorite(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + val favorite = !skin.favorite + launchLibraryCall( + onStatus, + null, + { SkinLibrary.setFavorite(skin.id, favorite) }, + SkinLibrary::failureMessage, + ) { if (favorite) "Added ${it.name} to favourites." else "Removed ${it.name} from favourites." } +} + +internal fun switchSkinModel(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + val model = if (skin.model == SkinModel.Slim) SkinModel.Classic else SkinModel.Slim + launchLibraryCall( + onStatus, + null, + { SkinLibrary.setModel(skin.id, model) }, + SkinLibrary::failureMessage, + ) { "${it.name} now uses ${if (it.model == SkinModel.Slim) "slim" else "classic"} arms." } +} + +internal fun deleteSkin(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + "Deleting ${skin.name}...", + { SkinLibrary.delete(skin.id) }, + SkinLibrary::failureMessage, + ) { "Deleted ${skin.name}." } +} + @Composable internal fun rememberCosmeticItems(refreshKey: Int): List = remember(refreshKey) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt index c317985d..977166ae 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.SolidColor import androidx.compose.runtime.ReadOnlyComposable @@ -29,7 +32,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -42,6 +47,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -71,6 +77,45 @@ internal fun SmallButton( } } +@Composable +internal fun WardrobeTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + maxLength: Int = 64, + onSubmit: (() -> Unit)? = null, +) { + val theme = LocalTheme.current + BasicTextField( + value = value, + onValueChange = { if (it.length <= maxLength) onValueChange(it) }, + singleLine = true, + textStyle = TextStyle( + color = theme.textColor, + fontSize = 12.sp, + fontFamily = theme.typography.family, + ), + cursorBrush = SolidColor(theme.textColor), + keyboardActions = onSubmit?.let { submit -> KeyboardActions(onDone = { submit() }) } ?: KeyboardActions.Default, + keyboardOptions = if (onSubmit != null) KeyboardOptions(imeAction = ImeAction.Done) else KeyboardOptions.Default, + modifier = modifier + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .trackTextInputFocus(), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + SocialText(placeholder, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1) + } + inner() + } + }, + ) +} + @Composable internal fun CenteredNote(text: String) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt index a5587426..6789f840 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,6 +39,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -64,6 +67,11 @@ import org.polyfrost.polyplus.client.cosmetics.CosmeticSwatches import org.polyfrost.polyplus.client.cosmetics.MojangCape import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.cosmetics.SwatchArt +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape import org.polyfrost.polyplus.client.gui.ppShapeOf @@ -95,6 +103,10 @@ internal fun WardrobeGrid( onBundle: (BundleInfo) -> Unit, onBuyBundle: (BundleInfo) -> Unit, onOpenStore: () -> Unit, + onLibraryAction: (WardrobeSection) -> Unit, + onSelectOutfit: (OutfitInfo) -> Unit, + onSelectSkin: (PlayerSkinInfo) -> Unit, + onStatus: (String) -> Unit, ) { LazyVerticalGrid( columns = GridCells.Fixed(3), @@ -262,6 +274,78 @@ internal fun WardrobeGrid( CoverThumbnail(bundle.assetId, modifier) } } + is WardrobeEntry.LibraryHeader -> LibraryIntro( + note = entry.note, + action = entry.action, + onAction = { onLibraryAction(entry.section) }, + ) + is WardrobeEntry.Outfit -> { + val outfit = entry.outfit + var renaming by remember { mutableStateOf(false) } + WardrobeCard( + name = outfit.name, + detail = { + CardDetailText(if (entry.active) "Active" else cosmeticCountLabel(outfit.equipped.size)) + if (renaming) { + RenamePopup( + current = outfit.name, + onSave = { renameOutfit(outfit, it, onStatus) }, + onDismiss = { renaming = false }, + ) + } + }, + action = wearAction(entry.active), + onAction = { wearOutfit(outfit, onStatus) }, + equipped = entry.active, + selected = selection == WardrobeSelection.Outfit(outfit.id), + enabled = !OutfitLibrary.busy, + badge = favoriteBadge(outfit.favorite), + onClick = { onSelectOutfit(outfit) }, + menu = listOf( + CardMenuAction("Wear", enabled = !entry.active) { wearOutfit(outfit, onStatus) }, + CardMenuAction("Rename") { renaming = true }, + CardMenuAction(favoriteMenuLabel(outfit.favorite)) { toggleOutfitFavorite(outfit, onStatus) }, + CardMenuAction("Delete") { deleteOutfit(outfit, onStatus) }, + ), + ) { modifier -> + OutfitThumbnail(outfit, modifier) + } + } + is WardrobeEntry.Skin -> { + val skin = entry.skin + var renaming by remember { mutableStateOf(false) } + WardrobeCard( + name = skin.name, + detail = { + CardDetailText(skin.model.label) + if (renaming) { + RenamePopup( + current = skin.name, + onSave = { renameSkin(skin, it, onStatus) }, + onDismiss = { renaming = false }, + ) + } + }, + action = wearAction(entry.worn), + onAction = { wearSkin(skin, onStatus) }, + equipped = entry.worn, + selected = selection == WardrobeSelection.Skin(skin.id), + enabled = !SkinLibrary.busy, + badge = favoriteBadge(skin.favorite), + onClick = { onSelectSkin(skin) }, + menu = listOf( + CardMenuAction("Wear", enabled = !entry.worn) { wearSkin(skin, onStatus) }, + CardMenuAction("Rename") { renaming = true }, + CardMenuAction(favoriteMenuLabel(skin.favorite)) { toggleSkinFavorite(skin, onStatus) }, + CardMenuAction( + if (skin.model == SkinModel.Slim) "Switch to classic arms" else "Switch to slim arms", + ) { switchSkinModel(skin, onStatus) }, + CardMenuAction("Delete") { deleteSkin(skin, onStatus) }, + ), + ) { modifier -> + SkinThumbnail(skin, modifier) + } + } } } } @@ -287,11 +371,26 @@ private class CardMenuAction(val label: String, val enabled: Boolean = true, val private enum class CardBadgeStyle { New, Sale, Accent } -private data class CardBadge(val label: String, val style: CardBadgeStyle) +private data class CardBadge(val label: String, val style: CardBadgeStyle, val icon: String? = null) private fun equipAction(equipped: Boolean): CardAction = if (equipped) CardAction("Equipped", "tick") else CardAction("Equip", "profiles") +private fun wearAction(wearing: Boolean): CardAction = + if (wearing) CardAction("Wearing", "tick", enabled = false) else CardAction("Wear", "profiles") + +private fun favoriteBadge(favorite: Boolean): CardBadge? = + if (favorite) CardBadge("", CardBadgeStyle.Accent, icon = "star-filled") else null + +private fun favoriteMenuLabel(favorite: Boolean): String = + if (favorite) "Remove from favourites" else "Add to favourites" + +private fun cosmeticCountLabel(count: Int): String = when (count) { + 0 -> "No cosmetics" + 1 -> "1 cosmetic" + else -> "$count cosmetics" +} + private val CARD_SUCCESS = Color(0xFF239A60) private val CARD_NEUTRAL_CHIP = Color(0xFFD9D9D9) private val CARD_FRAME = Color(0xFF232D32) @@ -502,10 +601,9 @@ private fun CardMenuButton( } @Composable -private fun CardMenuPopup(actions: List, enabled: Boolean, onDismiss: () -> Unit) { - val theme = LocalTheme.current +private fun rememberDropdownPosition(): PopupPositionProvider { val gap = with(LocalDensity.current) { 4.dp.roundToPx() } - val positionProvider = remember(gap) { + return remember(gap) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, @@ -525,8 +623,13 @@ private fun CardMenuPopup(actions: List, enabled: Boolean, onDis } } } +} + +@Composable +private fun CardMenuPopup(actions: List, enabled: Boolean, onDismiss: () -> Unit) { + val theme = LocalTheme.current Popup( - popupPositionProvider = positionProvider, + popupPositionProvider = rememberDropdownPosition(), onDismissRequest = onDismiss, properties = PopupProperties(focusable = true), ) { @@ -566,6 +669,52 @@ private fun CardMenuRow(label: String, enabled: Boolean, onClick: () -> Unit) { } } +@Composable +private fun RenamePopup(current: String, onSave: (String) -> Unit, onDismiss: () -> Unit) { + val theme = LocalTheme.current + var value by remember { mutableStateOf(current) } + val focusRequester = remember { FocusRequester() } + val save = { + val name = value.trim() + if (name.isNotEmpty()) { + onDismiss() + if (name != current) onSave(name) + } + } + Popup( + popupPositionProvider = rememberDropdownPosition(), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier.width(240.dp) + .clip(ppShape(16.dp)) + .background(theme.popupBackground) + .border(1.dp, theme.borderColor, ppShape(16.dp)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Rename", color = theme.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + WardrobeTextField( + value = value, + onValueChange = { value = it }, + placeholder = current, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + maxLength = MAX_LIBRARY_NAME_LENGTH, + onSubmit = save, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + SmallButton("Cancel", iconPath = "close", primary = false, onClick = onDismiss) + SmallButton("Save", iconPath = "tick", primary = true, onClick = save) + } + } + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + } +} + @Composable private fun CardActionStrip(action: CardAction, active: Boolean, enabled: Boolean, onAction: () -> Unit) { val theme = LocalTheme.current @@ -596,8 +745,9 @@ private fun CardActionStrip(action: CardAction, active: Boolean, enabled: Boolea @Composable private fun CardBadgeTab(badge: CardBadge, modifier: Modifier) { val theme = LocalTheme.current - Box( - modifier = modifier.widthIn(min = 81.dp).height(21.dp) + val color = if (badge.style == CardBadgeStyle.New) theme.textColor else theme.accentTextColor + Row( + modifier = modifier.widthIn(min = if (badge.label.isEmpty()) 40.dp else 81.dp).height(21.dp) .clip(ppShapeOf(bottomStart = 7.dp, bottomEnd = 7.dp)) .background( when (badge.style) { @@ -607,14 +757,20 @@ private fun CardBadgeTab(badge: CardBadge, modifier: Modifier) { }, ) .padding(horizontal = 10.dp), - contentAlignment = Alignment.Center, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { - SocialText( - badge.label, - color = if (badge.style == CardBadgeStyle.New) theme.textColor else theme.accentTextColor, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) + if (badge.icon != null) { + Icon(badge.icon, color = color, modifier = Modifier.size(12.dp)) + } + if (badge.label.isNotEmpty()) { + SocialText( + badge.label, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } } } @@ -631,3 +787,17 @@ private fun WardrobeBanner(text: String, action: String, onAction: () -> Unit) { SmallButton(action, iconPath = "keyboard", primary = false, onClick = onAction) } } + +@Composable +private fun LibraryIntro(note: String, action: String?, onAction: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.weight(1f)) + if (action != null) { + SmallButton(action, iconPath = "plus", primary = false, onClick = onAction) + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt index 481c514c..77d42890 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt @@ -96,7 +96,7 @@ internal fun PreviewModeToggle(wheelMode: Boolean, onChange: (Boolean) -> Unit) } @Composable -private fun PreviewModeOption(label: String, iconPath: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { +internal fun PreviewModeOption(label: String, iconPath: String?, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { val interaction = remember { MutableInteractionSource() } val hovered by interaction.collectIsHoveredAsState() val theme = LocalTheme.current @@ -111,7 +111,7 @@ private fun PreviewModeOption(label: String, iconPath: String, selected: Boolean horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { - Icon(iconPath, color = color, modifier = Modifier.size(14.dp)) + if (iconPath != null) Icon(iconPath, color = color, modifier = Modifier.size(14.dp)) SocialText(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium) } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt new file mode 100644 index 00000000..3295e54d --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt @@ -0,0 +1,474 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.nio.file.Files +import java.nio.file.Path +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.resources.Identifier +import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.PetDefinition +import org.polyfrost.polyplus.client.cosmetics.outfits.LibraryState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BodySlot +import org.polyfrost.polyplus.client.utils.ClientPlatform + + +internal const val MAX_LIBRARY_NAME_LENGTH = 32 + +private const val MAX_USERNAME_LENGTH = 16 + +private const val MAX_URL_LENGTH = 512 + +private const val SKIN_FIGURE_FILL = 0.85f + +internal val SkinModel.label: String + get() = when (this) { + SkinModel.Classic -> "Classic" + SkinModel.Slim -> "Slim" + } + +@Composable +internal fun OutfitThumbnail(outfit: OutfitInfo, modifier: Modifier) { + val installs = CosmeticAssetCache.installs + val textureVersion = MojangCapeService.textureVersion + val mojangState = MojangCapeService.state + LaunchedEffect(outfit.equipped) { + for ((slot, id) in outfit.equipped) { + if (!isOutfitAssetLoaded(slot, id)) CosmeticAssetCache.ensureCosmeticLoaded(id) + } + } + val mojangCape = remember(outfit.mojangCapeId, mojangState, textureVersion) { + val capeId = outfit.mojangCapeId ?: return@remember null + (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == capeId }?.let(MojangCapeService::texture) + } + val source = remember(outfit.equipped, installs, mojangCape) { outfitPreviewSource(outfit.equipped, mojangCape) } + val loaded = outfit.equipped.count { (slot, id) -> isOutfitAssetLoaded(slot, id) } + val capeId = outfit.equipped[BodySlot.Cape] + PlayerPreview( + modifier, + source = source, + autoSpin = false, + allowDrag = false, + modelScale = 0.42f, + verticalAnchor = 0.52f, + initialYaw = FRONT_YAW_DEG, + previewKey = "outfit-${outfit.id}-${outfit.updatedAt}-$loaded-${mojangCape != null}", + live = BodySlot.Aura in outfit.equipped || (capeId != null && CosmeticAssetCache.isCapeAnimated(capeId)), + ) +} + +private fun isOutfitAssetLoaded(slot: BodySlot, id: Int): Boolean = when (slot) { + BodySlot.Cape -> CosmeticAssetCache.isCapeLoaded(id) + BodySlot.Pet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null + else -> CosmeticAssetCache.getAttachedCosmetic(id) != null +} + +private fun outfitPreviewSource(equipped: Map, mojangCape: Identifier?): PlayerPreviewSource { + val equipment = CosmeticEquipment() + var pet: PetDefinition? = null + for ((slot, id) in equipped) { + if (slot == BodySlot.Cape) continue + val attached = CosmeticAssetCache.getAttachedCosmetic(id) + if (attached != null) { + equipment.equip(attached) + } else if (slot == BodySlot.Pet) { + pet = CosmeticAssetCache.getPetDefinition(id) + } + } + val capeId = equipped[BodySlot.Cape] + return PlayerPreviewSource.Override( + equipment, + pet = pet, + capeCosmeticId = capeId, + mojangCape = if (capeId == null) mojangCape else null, + ) +} + +@Composable +internal fun SkinThumbnail(skin: PlayerSkinInfo, modifier: Modifier) { + val version = SkinLibrary.bitmapVersion + val bitmap = remember(skin.hash, version) { SkinLibrary.bitmap(skin) } + val slim = skin.model == SkinModel.Slim + if (bitmap == null) { + Box(modifier) + } else { + Canvas(modifier) { drawSkinFront(bitmap, slim) } + } +} + +private fun DrawScope.drawSkinFront(skin: ImageBitmap, slim: Boolean) { + val legacy = skin.height <= 32 + val unit = floor(min(size.width / 16f, size.height / 32f) * SKIN_FIGURE_FILL).toInt().coerceAtLeast(1) + val originX = ((size.width - 16 * unit) / 2f).roundToInt() + val originY = ((size.height - 32 * unit) / 2f).roundToInt() + + fun part(u: Int, v: Int, w: Int, h: Int, x: Int, y: Int, mirror: Boolean = false) { + val dstOffset = IntOffset(originX + x * unit, originY + y * unit) + val dstSize = IntSize(w * unit, h * unit) + if (mirror) { + val pivot = Offset(dstOffset.x + dstSize.width / 2f, dstOffset.y + dstSize.height / 2f) + scale(-1f, 1f, pivot) { + drawImage(skin, IntOffset(u, v), IntSize(w, h), dstOffset, dstSize, filterQuality = FilterQuality.None) + } + } else { + drawImage(skin, IntOffset(u, v), IntSize(w, h), dstOffset, dstSize, filterQuality = FilterQuality.None) + } + } + + val arm = if (slim) 3 else 4 + part(8, 8, 8, 8, 4, 0) + part(20, 20, 8, 12, 4, 8) + part(44, 20, arm, 12, 4 - arm, 8) + if (legacy) part(44, 20, arm, 12, 12, 8, mirror = true) else part(36, 52, arm, 12, 12, 8) + part(4, 20, 4, 12, 4, 20) + if (legacy) part(4, 20, 4, 12, 8, 20, mirror = true) else part(20, 52, 4, 12, 8, 20) + if (!legacy) { + part(20, 36, 8, 12, 4, 8) + part(44, 36, arm, 12, 4 - arm, 8) + part(52, 52, arm, 12, 12, 8) + part(4, 36, 4, 12, 4, 20) + part(4, 52, 4, 12, 8, 20) + } + part(40, 8, 8, 8, 4, 0) +} + +@Composable +internal fun OutfitSwitcher(onStatus: (String) -> Unit) { + LaunchedEffect(Unit) { OutfitLibrary.refreshIfNeeded() } + val state = OutfitLibrary.state + if (state is LibraryState.Unavailable) return + val theme = LocalTheme.current + val outfits = OutfitLibrary.outfits.favoritesFirst() + val activeId = OutfitLibrary.activeOutfitId + val busy = OutfitLibrary.busy + val loaded = state == LibraryState.Loaded + val active = outfits.firstOrNull { it.id == activeId } + val canCycle = !busy && loaded && outfits.isNotEmpty() && (active == null || outfits.size > 1) + val label = when { + active != null -> active.name + !loaded -> "Loading outfits..." + outfits.isEmpty() -> "No saved outfits" + else -> "No outfit selected" + } + + fun cycle(step: Int) { + val index = outfits.indexOfFirst { it.id == activeId } + val next = if (index < 0) { + if (step > 0) outfits.first() else outfits.last() + } else { + outfits[(index + step).mod(outfits.size)] + } + wearOutfit(next, onStatus) + } + + Row( + modifier = Modifier.fillMaxWidth().height(28.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + WheelIconButton("left-arrow", enabled = canCycle) { cycle(-1) } + Box( + modifier = Modifier.weight(1f).fillMaxHeight() + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + CardLabel( + label, + color = if (active != null) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + WheelIconButton("right-arrow", enabled = canCycle) { cycle(1) } + WheelIconButton("plus", enabled = !busy && loaded, tooltip = "New outfit from current look") { + createOutfitFromCurrent(onStatus) + } + } +} + +private enum class SkinSource(val label: String, val icon: String) { + Username("Username", "profiles"), + Url("URL", "cloud"), + File("File", "folder"), +} + +@Composable +internal fun AddSkinPanel(onClose: () -> Unit, onStatus: (String) -> Unit, modifier: Modifier) { + val theme = LocalTheme.current + val sources = SkinSource.entries + var source by remember { mutableStateOf(SkinSource.Username) } + var username by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var file by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var model by remember { mutableStateOf(SkinModel.Classic) } + var picking by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + fun chooseFile() { + if (picking) return + picking = true + PolyPlusClient.SCOPE.launch { + val picked = withContext(Dispatchers.IO) { runCatching { pickSkinFile() }.getOrNull() } + ClientPlatform.runOnMain { + picking = false + if (picked != null) { + file = picked + message = null + } + } + } + } + + fun submit() { + if (working) return + val chosen = source + val player = username.trim() + val link = url.trim() + val path = file + val label = name.trim().ifEmpty { null } + val arms = model + val problem = when (chosen) { + SkinSource.Username -> if (player.isEmpty()) "Enter a Minecraft username." else null + SkinSource.Url -> if (link.isEmpty()) "Enter the link to a skin image." else null + SkinSource.File -> if (path == null) "Choose a PNG file first." else null + } + if (problem != null) { + message = problem + return + } + working = true + message = "Adding skin..." + PolyPlusClient.SCOPE.launch { + val result = when (chosen) { + SkinSource.Username -> SkinLibrary.addFromUsername(player) + SkinSource.Url -> SkinLibrary.addFromUrl(link, label, arms) + SkinSource.File -> { + val bytes = path?.let { readSkinFile(it) } + if (bytes == null) { + ClientPlatform.runOnMain { + working = false + message = "Couldn't read that file." + } + return@launch + } + SkinLibrary.addFromBytes(bytes, label, arms) + } + } + ClientPlatform.runOnMain { + working = false + result.fold( + onSuccess = { skin -> + message = null + onStatus("Added ${skin.name} to your skins.") + onClose() + }, + onFailure = { message = SkinLibrary.failureMessage(it) }, + ) + } + } + } + + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SocialText("Add skin", color = theme.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + WheelIconButton("close", enabled = true, tooltip = "Close", onClick = onClose) + } + SegmentedControl( + options = sources.map { it.label to it.icon }, + selectedIndex = sources.indexOf(source), + onSelect = { index -> + source = sources[index] + message = null + }, + ) + when (source) { + SkinSource.Username -> FieldGroup("Minecraft username") { + WardrobeTextField( + value = username, + onValueChange = { username = it }, + placeholder = "Username", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_USERNAME_LENGTH, + onSubmit = { submit() }, + ) + SocialText("Saves this player's current skin to your library without changing yours.", color = theme.textColorSecondary, fontSize = 12.sp) + } + SkinSource.Url -> FieldGroup("Image URL") { + WardrobeTextField( + value = url, + onValueChange = { url = it }, + placeholder = "https://", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_URL_LENGTH, + onSubmit = { submit() }, + ) + SocialText("Link to a 64×64 or 64×32 PNG skin. Adding it also puts it on.", color = theme.textColorSecondary, fontSize = 12.sp) + } + SkinSource.File -> FieldGroup("Skin file") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton(if (picking) "Choosing..." else "Choose file", iconPath = "folder", primary = false, onClick = { chooseFile() }) + CardLabel( + file?.fileName?.toString() ?: "No file chosen", + color = if (file != null) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + modifier = Modifier.weight(1f), + ) + } + SocialText("A 64×64 or 64×32 PNG skin. Adding it also puts it on.", color = theme.textColorSecondary, fontSize = 12.sp) + } + } + if (source != SkinSource.Username) { + FieldGroup("Name") { + WardrobeTextField( + value = name, + onValueChange = { name = it }, + placeholder = "Optional", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_LIBRARY_NAME_LENGTH, + ) + } + FieldGroup("Arms") { + SegmentedControl( + options = SkinModel.entries.map { it.label to null }, + selectedIndex = SkinModel.entries.indexOf(model), + onSelect = { model = SkinModel.entries[it] }, + ) + } + } + message?.let { SocialText(it, color = theme.textColorSecondary, fontSize = 12.sp) } + PrimaryWideButton(if (working) "Adding..." else "Add skin", iconPath = "plus", enabled = !working, onClick = { submit() }) + } +} + +@Composable +private fun SegmentedControl( + options: List>, + selectedIndex: Int, + onSelect: (Int) -> Unit, +) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().height(33.dp) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + options.forEachIndexed { index, (label, icon) -> + val selected = index == selectedIndex + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val color = if (selected) theme.accentTextColor else if (hovered) theme.textColor else theme.textColorSecondary + Row( + modifier = Modifier.weight(1f).fillMaxHeight() + .clip(ppShape(6.dp)) + .background(if (selected) Accent else Color.Transparent) + .hoverable(interaction) + .clickable { onSelect(index) }, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) Icon(icon, color = color, modifier = Modifier.size(12.dp)) + SocialText(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } + } + } +} + +@Composable +private fun PrimaryWideButton(label: String, iconPath: String, enabled: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().height(33.dp) + .alpha(if (enabled) 1f else 0.6f) + .clip(ppShape(8.dp)) + .background(Accent) + .clickable(enabled = enabled, onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(iconPath, color = theme.accentTextColor, modifier = Modifier.size(14.dp)) + SocialText(label, color = theme.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun FieldGroup(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SocialText(label, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + content() + } +} + +private suspend fun readSkinFile(path: Path): ByteArray? = + withContext(Dispatchers.IO) { runCatching { Files.readAllBytes(path) }.getOrNull() } + +private fun pickSkinFile(): Path? = + TinyFdApi.getInstance().openFileSelector("Choose a skin", null, arrayOf("*.png"), "PNG images") diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt index 130d145b..09d81f95 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.key import org.polyfrost.polyplus.client.cosmetics.BillingService import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo import org.polyfrost.polyplus.client.network.http.responses.BundleInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticType @@ -152,6 +154,19 @@ internal sealed interface WardrobeEntry { data class Bundle(val bundle: BundleInfo) : WardrobeEntry { override val key: String get() = "bundle-${bundle.id}" } + + data class LibraryHeader(val section: WardrobeSection, val note: String, val action: String? = null) : WardrobeEntry { + override val key: String get() = "library-${section.name}" + override val fullSpan: Boolean get() = true + } + + data class Outfit(val outfit: OutfitInfo, val active: Boolean) : WardrobeEntry { + override val key: String get() = "outfit-${outfit.id}" + } + + data class Skin(val skin: PlayerSkinInfo, val worn: Boolean) : WardrobeEntry { + override val key: String get() = "skin-${skin.id}" + } } internal sealed interface WardrobeSelection { @@ -159,8 +174,14 @@ internal sealed interface WardrobeSelection { data class Shop(val infoId: Int) : WardrobeSelection data class Mojang(val capeId: String) : WardrobeSelection data class Bundle(val bundleId: Int) : WardrobeSelection + data class Outfit(val outfitId: Int) : WardrobeSelection + data class Skin(val skinId: Int) : WardrobeSelection } +internal fun List.favoritesFirst(): List = sortedByDescending { it.favorite } + +internal fun List.favoriteSkinsFirst(): List = sortedByDescending { it.favorite } + internal val WARDROBE_COSMETIC_TYPES: List = CosmeticType.entries.filter { it != CosmeticType.Unknown && it != CosmeticType.Emote } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index 990b5c3a..e03132f7 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -31,6 +31,8 @@ import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind import org.polyfrost.polyplus.client.emotes.EmoteWheels import org.polyfrost.polyplus.client.gui.preview.PreviewRotation @@ -72,6 +74,7 @@ internal fun WardrobeShell( var selection by remember { mutableStateOf(null) } var showCart by remember { mutableStateOf(false) } var showHistory by remember { mutableStateOf(false) } + var addingSkin by remember { mutableStateOf(false) } var wheelMode by remember { mutableStateOf(false) } var wheelSlot by remember { mutableStateOf(null) } var returnPoint by remember { mutableStateOf(null) } @@ -89,7 +92,20 @@ internal fun WardrobeShell( ?.let { "The emote wheel keybind is also used by ${it.joinToString()}." } } } - val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort, emoteKeybindWarning) { + LaunchedEffect(section) { + if (!polyPlusAvailable) return@LaunchedEffect + when (section) { + WardrobeSection.Outfits -> OutfitLibrary.refreshIfNeeded() + WardrobeSection.Skins -> { + SkinLibrary.refreshIfNeeded() + OutfitLibrary.refreshIfNeeded() + } + else -> Unit + } + } + val outfitKey = listOf(OutfitLibrary.outfits, OutfitLibrary.activeOutfitId, OutfitLibrary.state) + val skinKey = listOf(SkinLibrary.skins, SkinLibrary.state, SkinLibrary.wornSkinId()) + val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort, emoteKeybindWarning, outfitKey, skinKey) { buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable, emoteKeybindWarning) } val anchors = remember(entries) { @@ -109,6 +125,11 @@ internal fun WardrobeShell( (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == sel.capeId } } val selectedBundle = (selection as? WardrobeSelection.Bundle)?.let { sel -> store.bundles.firstOrNull { it.id == sel.bundleId } } + val selectedOutfit = (selection as? WardrobeSelection.Outfit)?.let { sel -> OutfitLibrary.outfits.firstOrNull { it.id == sel.outfitId } } + val selectedSkin = (selection as? WardrobeSelection.Skin)?.let { sel -> SkinLibrary.skins.firstOrNull { it.id == sel.skinId } } + val previewHeading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION } + ?: selectedOutfit?.let { it.name to "Outfit" } + ?: selectedSkin?.let { it.name to "${it.model.label} skin" } val previewRotation = remember { PreviewRotation(FRONT_YAW_DEG) } val showBack = selectedMojang != null || selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || @@ -132,13 +153,19 @@ internal fun WardrobeShell( onSelect = { section = it showHistory = false + addingSkin = false }, onAnchor = { index -> scope.launch { gridState.scrollToItem(index) } }, ) Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { WardrobeHeader( - title = if (showHistory) "History" else section.label, + title = when { + showHistory -> "History" + section == WardrobeSection.Outfits -> "Outfits (${OutfitLibrary.outfits.size}/${OutfitLibrary.limit})" + section == WardrobeSection.Skins -> "Skins (${SkinLibrary.skins.size}/${SkinLibrary.limit})" + else -> section.label + }, sort = sort, cartSize = cart.size, showingHistory = showHistory, @@ -148,6 +175,15 @@ internal fun WardrobeShell( onRefresh = { onRefresh() storeReloadKey++ + if (polyPlusAvailable && section == WardrobeSection.Outfits) { + scope.launch { OutfitLibrary.refresh() } + } + if (polyPlusAvailable && section == WardrobeSection.Skins) { + scope.launch { + SkinLibrary.refresh() + OutfitLibrary.refresh() + } + } }, onCart = { showCart = !showCart }, ) @@ -215,6 +251,21 @@ internal fun WardrobeShell( }, onBuyBundle = onBuyBundle, onOpenStore = { section = WardrobeSection.Featured }, + onLibraryAction = { target -> + if (target == WardrobeSection.Skins) { + addingSkin = true + showCart = false + } + }, + onSelectOutfit = { outfit -> + selection = WardrobeSelection.Outfit(outfit.id) + showCart = false + }, + onSelectSkin = { skin -> + selection = WardrobeSelection.Skin(skin.id) + showCart = false + }, + onStatus = onStatus, ) } } @@ -237,6 +288,7 @@ internal fun WardrobeShell( section = WardrobeSection.Emotes showHistory = false showCart = false + addingSkin = false } else { val back = returnPoint returnPoint = null @@ -252,6 +304,9 @@ internal fun WardrobeShell( } }, ) + if (!wheelMode) { + OutfitSwitcher(onStatus = onStatus) + } } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when { @@ -262,6 +317,11 @@ internal fun WardrobeShell( onRemove = onRemoveFromCart, onCheckout = onCheckout, ) + addingSkin && polyPlusAvailable -> AddSkinPanel( + onClose = { addingSkin = false }, + onStatus = onStatus, + modifier = Modifier.fillMaxSize(), + ) wheelMode -> EmoteWheelEditor( selectedSlot = wheelSlot, coverForEmote = { id -> store.all.firstOrNull { info -> info.variantList.any { it.id == id } }?.coverAssetId }, @@ -291,8 +351,8 @@ internal fun WardrobeShell( ) else -> { PreviewPanel( - selected = selectedOwned?.takeIf { selectedMojang == null }, - heading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION }, + selected = selectedOwned?.takeIf { previewHeading == null }, + heading = previewHeading, status = status, selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt index 378c3367..3d615ebb 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.coroutineScope import org.polyfrost.polyplus.client.cosmetics.BundleCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticStore import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.outfits.LibraryState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary import org.polyfrost.polyplus.client.network.http.responses.BundleInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo import org.polyfrost.polyplus.client.network.http.responses.CosmeticType @@ -104,11 +107,58 @@ internal fun buildWardrobeEntries( } } - WardrobeSection.Outfits -> - entries += WardrobeEntry.Note("outfits", "Saved outfits aren't available yet.") + WardrobeSection.Outfits -> { + val library = OutfitLibrary.outfits + entries += WardrobeEntry.LibraryHeader( + WardrobeSection.Outfits, + "Save your look and switch between outfits.", + ) + when (val state = OutfitLibrary.state) { + LibraryState.Idle, LibraryState.Loading -> + entries += WardrobeEntry.Note("outfits", "Loading your outfits...") + is LibraryState.Unavailable -> + entries += WardrobeEntry.Note("outfits", state.reason) + LibraryState.Loaded -> { + val activeId = OutfitLibrary.activeOutfitId + val outfits = library.filter { matches(it.name) }.favoritesFirst() + when { + library.isEmpty() -> entries += WardrobeEntry.Note( + "outfits", + "You have no saved outfits yet. Use + above the preview to save your current look.", + ) + outfits.isEmpty() -> entries += WardrobeEntry.Note("outfits", "No outfits match your search.") + else -> outfits.mapTo(entries) { WardrobeEntry.Outfit(it, it.id == activeId) } + } + } + } + } - WardrobeSection.Skins -> - entries += WardrobeEntry.Note("skins", "The skin library isn't available yet.") + WardrobeSection.Skins -> { + val library = SkinLibrary.skins + entries += WardrobeEntry.LibraryHeader( + WardrobeSection.Skins, + "Every skin you wear is saved here automatically.", + action = "Add skin", + ) + when (val state = SkinLibrary.state) { + LibraryState.Idle, LibraryState.Loading -> + entries += WardrobeEntry.Note("skins", "Loading your skins...") + is LibraryState.Unavailable -> + entries += WardrobeEntry.Note("skins", state.reason) + LibraryState.Loaded -> { + val wornId = SkinLibrary.wornSkinId() + val skins = library.filter { matches(it.name) }.favoriteSkinsFirst() + when { + library.isEmpty() -> entries += WardrobeEntry.Note( + "skins", + "You have no saved skins yet. Use Add skin to save one.", + ) + skins.isEmpty() -> entries += WardrobeEntry.Note("skins", "No skins match your search.") + else -> skins.mapTo(entries) { WardrobeEntry.Skin(it, it.id == wornId) } + } + } + } + } WardrobeSection.Emotes -> { if (emoteKeybindWarning != null) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt index 5fbdbea9..fb6c4dac 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt @@ -193,17 +193,21 @@ private fun WheelSlotCell( } @Composable -private fun WheelIconButton(iconPath: String, enabled: Boolean, onClick: () -> Unit) { +internal fun WheelIconButton(iconPath: String, enabled: Boolean, tooltip: String? = null, onClick: () -> Unit) { val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() Box( modifier = Modifier.size(28.dp) .alpha(if (enabled) 1f else 0.4f) .clip(ppShape(8.dp)) .background(theme.chipBackground) .border(1.dp, theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) .clickable(enabled = enabled, onClick = onClick), contentAlignment = Alignment.Center, ) { Icon(iconPath, color = theme.textColor, modifier = Modifier.size(14.dp)) + if (tooltip != null && hovered) TooltipPopup(tooltip) } } From 7a121dc7e330d6e7f78f8253e38e0e003e87b8a8 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 14:25:40 +0200 Subject: [PATCH 14/22] feat: replace the wardrobe sidebar with header chip rows --- .../client/gui/wardrobe/WardrobeHeader.kt | 125 +++++++++++++++--- .../client/gui/wardrobe/WardrobeRail.kt | 105 --------------- .../client/gui/wardrobe/WardrobeShell.kt | 31 ++--- .../client/gui/wardrobe/WardrobeStoreData.kt | 4 +- 4 files changed, 120 insertions(+), 145 deletions(-) delete mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt index 77d42890..4716ad7a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt @@ -3,23 +3,27 @@ package org.polyfrost.polyplus.client.gui.wardrobe import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.dp import androidx.compose.ui.unit.sp @@ -31,37 +35,118 @@ import org.polyfrost.polyplus.client.gui.ppShape @Composable internal fun WardrobeHeader( - title: String, + sections: List, + selected: WardrobeSection, + anchors: List>, + activeAnchor: Int?, sort: WardrobeSort, cartSize: Int, showingHistory: Boolean, storeEnabled: Boolean, + onSelectSection: (WardrobeSection) -> Unit, + onAnchor: (Int) -> Unit, onCycleSort: () -> Unit, onToggleHistory: () -> Unit, onRefresh: () -> Unit, onCart: () -> Unit, ) { + val theme = LocalTheme.current + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (section in sections) { + SectionChip(section, selected = section == selected && !showingHistory, onClick = { onSelectSection(section) }) + } + } + if (storeEnabled) { + SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) + } + } + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showingHistory) { + SocialText("Purchase history", color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + } else { + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (anchors.size > 1) { + for ((label, index) in anchors) { + AnchorChip(label, active = index == activeAnchor, onClick = { onAnchor(index) }) + } + } + } + } + if (storeEnabled && !showingHistory) { + SmallButton(sort.label, iconPath = "align", primary = false, onClick = onCycleSort) + } + if (storeEnabled) { + HeaderIconButton( + iconPath = if (showingHistory) "left-arrow" else "clipboard", + tooltip = if (showingHistory) "Back to the wardrobe" else "Purchase history", + onClick = onToggleHistory, + ) + } + HeaderIconButton(iconPath = "refresh", tooltip = "Refresh", onClick = onRefresh) + } + } +} + +@Composable +private fun SectionChip(section: WardrobeSection, selected: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val color = if (selected) theme.accentTextColor else theme.textColor Row( - modifier = Modifier.fillMaxWidth().height(33.dp), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(33.dp) + .clip(ppShape(8.dp)) + .background(if (selected) Accent else theme.chipBackground) + .border(1.dp, if (hovered && !selected) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - SocialText(title, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, maxLines = 1) - Spacer(Modifier.weight(1f)) - if (storeEnabled && !showingHistory) { - SmallButton(sort.label, iconPath = "align", primary = false, onClick = onCycleSort) - } - if (storeEnabled) { - HeaderIconButton( - iconPath = if (showingHistory) "left-arrow" else "clipboard", - tooltip = if (showingHistory) "Back to the wardrobe" else "Purchase history", - onClick = onToggleHistory, - ) - } - HeaderIconButton(iconPath = "refresh", tooltip = "Refresh", onClick = onRefresh) - if (storeEnabled) { - SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) - } + Icon(section.icon, color = color, modifier = Modifier.size(14.dp)) + SocialText(section.label, color = color, fontSize = 14.sp, fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal) + } +} + +@Composable +private fun AnchorChip(label: String, active: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box( + modifier = Modifier.height(26.dp) + .clip(ppShape(6.dp)) + .background(if (active) theme.chipBackground else Color.Transparent) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + SocialText( + label, + color = if (active || hovered) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + fontWeight = if (active) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + ) } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt deleted file mode 100644 index a163a56f..00000000 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeRail.kt +++ /dev/null @@ -1,105 +0,0 @@ -package org.polyfrost.polyplus.client.gui.wardrobe - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.unit.dp -import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.polyplus.client.gui.SocialText -import org.polyfrost.polyplus.client.gui.ppShape - -@Composable -internal fun WardrobeRail( - sections: List, - selected: WardrobeSection, - anchors: List>, - modifier: Modifier, - onSelect: (WardrobeSection) -> Unit, - onAnchor: (Int) -> Unit, -) { - Column( - modifier = modifier.wardrobePanel() - .verticalScroll(rememberScrollState()) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - for (section in sections) { - RailEntry(section, active = section == selected, onClick = { onSelect(section) }) - if (section == selected && anchors.size > 1) { - Column( - modifier = Modifier.padding(start = 34.dp, top = 2.dp, bottom = 6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - for ((label, index) in anchors) { - RailAnchor(label, onClick = { onAnchor(index) }) - } - } - } - } - } -} - -@Composable -private fun RailEntry(section: WardrobeSection, active: Boolean, onClick: () -> Unit) { - val interaction = remember { MutableInteractionSource() } - val hovered by interaction.collectIsHoveredAsState() - val theme = LocalTheme.current - val color = if (active) theme.accentTextColor else theme.textColor - Row( - modifier = Modifier.fillMaxWidth().height(33.dp) - .clip(ppShape(8.dp)) - .background( - when { - active -> Accent - hovered -> theme.chipBackground - else -> Color.Transparent - }, - ) - .hoverable(interaction) - .clickable(onClick = onClick) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(section.icon, color = color, modifier = Modifier.size(16.dp)) - SocialText(section.label, color = color, fontSize = 14.sp) - } -} - -@Composable -private fun RailAnchor(label: String, onClick: () -> Unit) { - val interaction = remember { MutableInteractionSource() } - val hovered by interaction.collectIsHoveredAsState() - val theme = LocalTheme.current - SocialText( - label, - color = if (hovered) theme.textColor else theme.textColorSecondary, - fontSize = 12.sp, - maxLines = 1, - modifier = Modifier.fillMaxWidth() - .hoverable(interaction) - .clickable(onClick = onClick) - .padding(vertical = 3.dp), - ) -} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index e03132f7..dc5bb30c 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf @@ -113,6 +114,9 @@ internal fun WardrobeShell( (entry as? WardrobeEntry.Divider)?.takeIf { it.anchor }?.let { it.label to index } } } + val activeAnchor by remember(anchors) { + derivedStateOf { anchors.lastOrNull { it.second <= gridState.firstVisibleItemIndex }?.second ?: anchors.firstOrNull()?.second } + } LaunchedEffect(section) { val target = pendingScroll pendingScroll = null @@ -145,27 +149,18 @@ internal fun WardrobeShell( } Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { - WardrobeRail( - sections = sections, - selected = section, - anchors = anchors, - modifier = Modifier.width(150.dp).fillMaxHeight(), - onSelect = { - section = it - showHistory = false - addingSkin = false - }, - onAnchor = { index -> scope.launch { gridState.scrollToItem(index) } }, - ) - Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { WardrobeHeader( - title = when { - showHistory -> "History" - section == WardrobeSection.Outfits -> "Outfits (${OutfitLibrary.outfits.size}/${OutfitLibrary.limit})" - section == WardrobeSection.Skins -> "Skins (${SkinLibrary.skins.size}/${SkinLibrary.limit})" - else -> section.label + sections = sections, + selected = section, + anchors = anchors, + activeAnchor = activeAnchor, + onSelectSection = { + section = it + showHistory = false + addingSkin = false }, + onAnchor = { index -> scope.launch { gridState.scrollToItem(index) } }, sort = sort, cartSize = cart.size, showingHistory = showHistory, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt index 3d615ebb..839a856a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -111,7 +111,7 @@ internal fun buildWardrobeEntries( val library = OutfitLibrary.outfits entries += WardrobeEntry.LibraryHeader( WardrobeSection.Outfits, - "Save your look and switch between outfits.", + "${library.size} of ${OutfitLibrary.limit} saved. Save your look and switch between outfits.", ) when (val state = OutfitLibrary.state) { LibraryState.Idle, LibraryState.Loading -> @@ -137,7 +137,7 @@ internal fun buildWardrobeEntries( val library = SkinLibrary.skins entries += WardrobeEntry.LibraryHeader( WardrobeSection.Skins, - "Every skin you wear is saved here automatically.", + "${library.size} of ${SkinLibrary.limit} saved. Every skin you wear is saved here automatically.", action = "Add skin", ) when (val state = SkinLibrary.state) { From 29cd6c8381968135b35a939f3d85cf1036e4011b Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 14:25:53 +0200 Subject: [PATCH 15/22] feat: wrap cosmetic variants as chips across the full width --- .../client/gui/wardrobe/WardrobePreview.kt | 252 ++++++++---------- 1 file changed, 111 insertions(+), 141 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt index 8a3397a9..d0b33438 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt @@ -1,38 +1,37 @@ package org.polyfrost.polyplus.client.gui.wardrobe +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +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.rememberScrollState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -41,11 +40,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties -import kotlinx.coroutines.launch -import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticSwatches +import org.polyfrost.polyplus.client.cosmetics.SwatchArt import org.polyfrost.polyplus.client.gui.SocialText import org.polyfrost.polyplus.client.gui.ppShape import org.polyfrost.polyplus.client.gui.preview.PlayerPreview @@ -97,33 +96,44 @@ internal fun PreviewPanel( } val variants = if (selected != null && selected.hasVariants) selected.variants else emptyList() if (variants.isNotEmpty() || showAuraColor) { - PreviewPill( - variants = variants, - selectedVariantId = selectedVariantId, - onSelectVariant = onSelectVariant, - showAuraColor = showAuraColor, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, - modifier = Modifier.align(Alignment.BottomCenter).padding(18.dp), - ) + Column( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (variants.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth() + .clip(ppShape(12.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Variants", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + VariantChips(variants, selectedVariantId, onSelectVariant) + } + } + if (showAuraColor) { + PreviewPill( + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } } } } @Composable private fun PreviewPill( - variants: List, - selectedVariantId: Int?, - onSelectVariant: (Int) -> Unit, - showAuraColor: Boolean, auraColor: Int?, onPreviewAuraColor: (Int) -> Unit, onSelectAuraColor: (Int?) -> Unit, modifier: Modifier = Modifier, ) { var customOpen by remember { mutableStateOf(false) } - LaunchedEffect(showAuraColor) { if (!showAuraColor) customOpen = false } var pillHeight by remember { mutableIntStateOf(0) } val popoverGapPx = with(LocalDensity.current) { 8.dp.roundToPx() } @@ -139,26 +149,14 @@ private fun PreviewPill( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (variants.isNotEmpty()) { - VariantStrip( - variants = variants, - selectedVariantId = selectedVariantId, - onSelect = onSelectVariant, - ) - } - if (variants.isNotEmpty() && showAuraColor) { - Box(Modifier.width(1.dp).height(20.dp).background(LocalTheme.current.borderColor)) - } - if (showAuraColor) { - AuraSwatchRow( - selectedColor = auraColor, - customOpen = customOpen, - onToggleCustom = { customOpen = !customOpen }, - onCommit = onSelectAuraColor, - ) - } + AuraSwatchRow( + selectedColor = auraColor, + customOpen = customOpen, + onToggleCustom = { customOpen = !customOpen }, + onCommit = onSelectAuraColor, + ) } - if (customOpen && showAuraColor) { + if (customOpen) { Popup( alignment = Alignment.TopEnd, offset = IntOffset(0, pillHeight + popoverGapPx), @@ -176,121 +174,93 @@ private fun PreviewPill( } @Composable -private fun RowScope.VariantStrip( +internal fun VariantPicker( variants: List, selectedVariantId: Int?, onSelect: (Int) -> Unit, + ownedVariantIds: Set = emptySet(), ) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - val wheelStep = with(LocalDensity.current) { VARIANT_WHEEL_STEP.toPx() } - val arrowStep = with(LocalDensity.current) { VARIANT_ARROW_STEP.toPx() } - val overflows = scrollState.maxValue > 0 && scrollState.maxValue != Int.MAX_VALUE - - if (overflows) { - ScrollArrow( - iconName = "left-arrow", - enabled = scrollState.value > 0, - onClick = { scope.launch { scrollState.animateScrollBy(-arrowStep) } }, - ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + VariantChips(variants, selectedVariantId, onSelect, ownedVariantIds) } - Row( - modifier = Modifier.weight(1f, fill = false) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type != PointerEventType.Scroll) continue - val delta = event.changes.first().scrollDelta - if (delta.x == 0f && delta.y != 0f) { - scope.launch { scrollState.scrollBy(delta.y * wheelStep) } - event.changes.forEach { it.consume() } - } - } - } - } - .horizontalScroll(scrollState), +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun VariantChips( + variants: List, + selectedVariantId: Int?, + onSelect: (Int) -> Unit, + ownedVariantIds: Set = emptySet(), +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { for (variant in variants) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.componentBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) - } + VariantChip( + variant = variant, + selected = variant.id == selectedVariantId, + dimmed = variant.id in ownedVariantIds && variant.id != selectedVariantId, + onClick = { onSelect(variant.id) }, + ) } } - if (overflows) { - ScrollArrow( - iconName = "right-arrow", - enabled = scrollState.value < scrollState.maxValue, - onClick = { scope.launch { scrollState.animateScrollBy(arrowStep) } }, - ) - } } @Composable -private fun ScrollArrow(iconName: String, enabled: Boolean, onClick: () -> Unit) { - Box( - modifier = Modifier.size(18.dp) +private fun VariantChip(variant: CosmeticVariantUi, selected: Boolean, dimmed: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val swatchVersion = CosmeticSwatches.version + val art = remember(variant.id, swatchVersion) { CosmeticSwatches.get(variant.id) } + Row( + modifier = Modifier.height(28.dp) + .alpha(if (dimmed) 0.55f else 1f) .clip(ppShape(6.dp)) - .background(LocalTheme.current.componentBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) - .alpha(if (enabled) 1f else 0.35f) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, + .background(if (selected) Accent else theme.componentBackground) + .border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> theme.borderColor + }, + ppShape(6.dp), + ) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(start = 4.dp, end = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon(iconName, color = LocalTheme.current.textColor, modifier = Modifier.size(10.dp)) - } -} - -@Composable -internal fun VariantPicker( - variants: List, - selectedVariantId: Int?, - onSelect: (Int) -> Unit, - ownedVariantIds: Set = emptySet(), -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.width(220.dp)) { - SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - for (row in variants.chunked(2)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - for (variant in row) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .alpha(if (variant.id in ownedVariantIds && !isSelected) 0.55f else 1f) - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) + Box(Modifier.size(width = 13.dp, height = 20.dp).clip(ppShape(4.dp)).background(theme.chipBackground)) { + when (art) { + is SwatchArt.Picture -> Image( + bitmap = art.bitmap, + contentDescription = null, + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize(), + ) + is SwatchArt.Stripes -> Column(Modifier.fillMaxSize()) { + for (color in art.colors) { + Box(Modifier.fillMaxWidth().weight(1f).background(color)) } } + null -> Unit } } + SocialText( + variant.name, + color = if (selected) theme.accentTextColor else theme.textColor, + fontSize = 12.sp, + maxLines = 1, + ) } } private val PILL_RADIUS = 18.dp - -private val VARIANT_WHEEL_STEP = 48.dp - -private val VARIANT_ARROW_STEP = 96.dp From 959ae020e6e1c35a56afedde5e942439163e9013 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 14:26:08 +0200 Subject: [PATCH 16/22] feat: show four cards per wardrobe row --- .../org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt index 6789f840..66bb4f76 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -109,7 +109,7 @@ internal fun WardrobeGrid( onStatus: (String) -> Unit, ) { LazyVerticalGrid( - columns = GridCells.Fixed(3), + columns = GridCells.Fixed(4), state = state, modifier = modifier, contentPadding = PaddingValues(bottom = 12.dp), From 178ff53ba77af0ec4e8d74308f5018b99d93b5d0 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 14:26:12 +0200 Subject: [PATCH 17/22] feat: rework the cape headings and force toggle --- .../client/gui/wardrobe/WardrobeCapes.kt | 125 ++++++++++++------ .../client/gui/wardrobe/WardrobeGrid.kt | 9 +- .../client/gui/wardrobe/WardrobeModels.kt | 7 +- .../client/gui/wardrobe/WardrobeShell.kt | 1 - .../client/gui/wardrobe/WardrobeStoreData.kt | 6 +- .../client/gui/wardrobe/WardrobeThumbnails.kt | 2 +- 6 files changed, 91 insertions(+), 59 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt index dc9be58a..f0fa0c73 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt @@ -1,15 +1,23 @@ package org.polyfrost.polyplus.client.gui.wardrobe +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState 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.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.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,62 +27,99 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig import org.polyfrost.polyplus.client.cosmetics.CapeResolver import org.polyfrost.polyplus.client.gui.SocialText -import org.polyfrost.polyplus.client.gui.SocialToggle +import org.polyfrost.polyplus.client.gui.clickableWithSound import org.polyfrost.polyplus.client.gui.ppShape @Composable -internal fun ForceCapesControl() { - var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } - val modName = CapeResolver.localOverrideModName - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - SocialToggle("Force PolyPlus capes", checked = forced) { - forced = !forced - PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced - PolyPlusCosmeticsConfig.save() - } - val note = when { - modName == null -> "When another mod sets its own cape, that cape is shown unless this is on." - forced -> "$modName sets its own cape, but the capes picked here are shown instead." - else -> "Your cape is set by $modName. Turn this on to show the capes picked here." - } - SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) +internal fun CapeGroupHeading(title: String, note: String, forceToggle: Boolean) { + Row( + modifier = Modifier.fillMaxWidth().height(28.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + InfoTip(note) + Spacer(Modifier.weight(1f)) + if (forceToggle) ForceCapesToggle() } } @Composable -internal fun CapeSectionHeader( - title: String, - note: String, - action: String? = null, - onAction: () -> Unit = {}, -) { +private fun ForceCapesToggle() { + var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } + val modName = CapeResolver.localOverrideModName + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val toggle = { + forced = !forced + PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced + PolyPlusCosmeticsConfig.save() + } Row( - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + modifier = Modifier.hoverable(interaction).clickableWithSound(toggle), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - if (action != null) { - Box( - modifier = Modifier.height(28.dp) - .clip(ppShape(8.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) - .clickable(onClick = onAction) - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center, - ) { - SocialText(action, color = LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + Box { + SocialText( + if (modName != null) "Show over $modName" else "Show over other mods", + color = if (hovered) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + if (hovered) { + TooltipPopup( + if (modName != null) { + "$modName sets your cape. Turn this on to show your PolyPlus cape instead, or your Minecraft cape if you have none." + } else { + "When another mod sets your cape, its cape is shown. Turn this on to show your PolyPlus cape instead, or your Minecraft cape if you have none." + }, + ) } } + CapeSwitch(forced) + } +} + +@Composable +private fun CapeSwitch(checked: Boolean) { + val theme = LocalTheme.current + val track by animateColorAsState(if (checked) Accent else theme.controlTrackColor) + val thumbOffset by animateDpAsState(if (checked) 19.dp else 3.dp, animationSpec = spring()) + Box( + modifier = Modifier.size(width = 34.dp, height = 18.dp) + .clip(theme.circleShape) + .background(track), + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier.offset(x = thumbOffset) + .size(12.dp) + .background(theme.controlThumbColor, theme.circleShape), + ) + } +} + +@Composable +private fun InfoTip(text: String) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box(Modifier.size(16.dp).hoverable(interaction), contentAlignment = Alignment.Center) { + Icon("info", color = if (hovered) theme.textColor else theme.textColorSecondary, modifier = Modifier.size(14.dp)) + if (hovered) TooltipPopup(text) } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt index 66bb4f76..8e98b646 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -102,7 +102,6 @@ internal fun WardrobeGrid( onMojang: (MojangCape) -> Unit, onBundle: (BundleInfo) -> Unit, onBuyBundle: (BundleInfo) -> Unit, - onOpenStore: () -> Unit, onLibraryAction: (WardrobeSection) -> Unit, onSelectOutfit: (OutfitInfo) -> Unit, onSelectSkin: (PlayerSkinInfo) -> Unit, @@ -123,15 +122,9 @@ internal fun WardrobeGrid( ) { entry -> when (entry) { is WardrobeEntry.Divider -> WardrobeDivider(entry.label) - is WardrobeEntry.Group -> CapeSectionHeader( - title = entry.title, - note = entry.note, - action = entry.action, - onAction = onOpenStore, - ) + is WardrobeEntry.Group -> CapeGroupHeading(entry.title, entry.note, entry.forceToggle) is WardrobeEntry.Note -> CapeSectionNote(entry.text) is WardrobeEntry.Banner -> WardrobeBanner(entry.text, entry.action, onAction = { OneConfigUI.open(KeybindsGraph) }) - WardrobeEntry.ForceCapes -> ForceCapesControl() is WardrobeEntry.Owned -> { val item = entry.item val variantId = selectedVariantId(item, variantPicks) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt index 09d81f95..c00493c0 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -119,7 +119,7 @@ internal sealed interface WardrobeEntry { override val fullSpan: Boolean get() = true } - data class Group(val id: String, val title: String, val note: String, val action: String? = null) : WardrobeEntry { + data class Group(val id: String, val title: String, val note: String, val forceToggle: Boolean = false) : WardrobeEntry { override val key: String get() = "group-$id" override val fullSpan: Boolean get() = true } @@ -134,11 +134,6 @@ internal sealed interface WardrobeEntry { override val fullSpan: Boolean get() = true } - data object ForceCapes : WardrobeEntry { - override val key: String get() = "force-capes" - override val fullSpan: Boolean get() = true - } - data class Owned(val item: CosmeticUiItem) : WardrobeEntry { override val key: String get() = "owned-${item.type.serializedName}-${item.groupId}" } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index dc5bb30c..f565ef76 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -245,7 +245,6 @@ internal fun WardrobeShell( showCart = false }, onBuyBundle = onBuyBundle, - onOpenStore = { section = WardrobeSection.Featured }, onLibraryAction = { target -> if (target == WardrobeSection.Skins) { addingSkin = true diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt index 839a856a..e96d3777 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -185,10 +185,9 @@ internal fun buildWardrobeEntries( val shopItems = shop(type) if (type == CosmeticType.Cape) { entries += WardrobeEntry.Divider(type.displayName, anchor = true) - entries += WardrobeEntry.ForceCapes entries += WardrobeEntry.Group( "minecraft", - "Minecraft", + "Minecraft capes", "Everyone sees this cape, including players without PolyPlus.", ) when (mojangState) { @@ -207,8 +206,9 @@ internal fun buildWardrobeEntries( } entries += WardrobeEntry.Group( "polyplus", - "PolyPlus", + "PolyPlus capes", "Players with PolyPlus see this cape instead of your Minecraft cape.", + forceToggle = polyPlusAvailable, ) if (!polyPlusAvailable) { entries += WardrobeEntry.Note( diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt index 41629cdd..94c6adcb 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt @@ -88,7 +88,7 @@ private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (t CosmeticType.Shoulder -> PreviewFraming(FRONT_YAW_DEG, 0.58f, 0.62f) CosmeticType.Wings -> PreviewFraming(BACK_YAW_DEG, 0.5f, 0.5f) CosmeticType.Backpack -> PreviewFraming(BACK_YAW_DEG, 0.55f, 0.55f) - CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.42f, 0.5f) + CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.68f, 0.5f) CosmeticType.Aura -> PreviewFraming(FRONT_YAW_DEG, 0.5f, 0.5f) CosmeticType.Glove -> PreviewFraming(FRONT_YAW_DEG, 0.7f, 0.42f) else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) From 8722e026cff9db6a3d0514716256d0b13a79d966 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 15:26:04 +0200 Subject: [PATCH 18/22] feat: give players eight starter outfits --- .../client/cosmetics/outfits/OutfitLibrary.kt | 29 ++++++++++++++++--- .../client/cosmetics/outfits/OutfitModels.kt | 3 ++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt index a433e5b4..d94989fa 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -75,7 +75,7 @@ object OutfitLibrary { OutfitApi.send(HttpMethod.Post, "/outfits", body) .map { it.toInfo() } .onSuccess { created -> - outfits = listOf(created) + outfits.filter { it.id != created.id } + outfits = outfits.filter { it.id != created.id } + created activeOutfitId = created.id refreshLocked() } @@ -179,6 +179,9 @@ object OutfitLibrary { limit = response.limit loadedFor = owner state = LibraryState.Loaded + activeOutfit()?.takeIf { it.blank }?.let { blank -> + PolyPlusClient.SCOPE.launch { adoptCurrentLook(blank) } + } } .onFailure { error -> LOGGER.error("Failed to fetch outfits", error) @@ -197,6 +200,9 @@ object OutfitLibrary { response.skin?.let { skin -> SkinLibrary.remember(skin.toInfo()) } val problems = mutableListOf() + if (outfit.blank) { + adoptCurrentLook(outfit) + } response.skin?.toInfo()?.let { skin -> val current = MojangSkinService.activeSkin().getOrNull() if (current == null || current.textureHash != skin.hash || current.model != skin.model) { @@ -207,8 +213,10 @@ object OutfitLibrary { } } } - applyMojangCape(outfit.mojangCapeId).onFailure { - problems += "your Minecraft cape wasn't changed (${MojangCapeService.failureMessage(it)})" + if (!outfit.blank) { + applyMojangCape(outfit.mojangCapeId).onFailure { + problems += "your Minecraft cape wasn't changed (${MojangCapeService.failureMessage(it)})" + } } CosmeticService.syncLocalActive().onFailure { LOGGER.warn("Failed to resync cosmetics after applying outfit {}", id, it) } @@ -228,6 +236,19 @@ object OutfitLibrary { return Result.failure(OutfitException(message)) } + private suspend fun adoptCurrentLook(outfit: OutfitInfo) { + val skinId = SkinLibrary.recordCurrent() + .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } + .getOrElse { return }?.id + MojangCapeService.refreshIfNeeded() + if (MojangCapeService.state !is MojangCapeState.Loaded) return + val capeId = MojangCapeService.activeCape()?.id + patch(outfit.id, buildJsonObject { + if (skinId != null) put("skin_id", skinId) + put("mojang_cape_id", capeId) + }) + } + private suspend fun applyMojangCape(capeId: String?): Result = runSuspendCatching { MojangCapeService.refreshIfNeeded() val loaded = MojangCapeService.state is MojangCapeState.Loaded @@ -247,7 +268,7 @@ object OutfitLibrary { outfits = if (existing.any { it.id == outfit.id }) { existing.map { if (it.id == outfit.id) outfit else it } } else { - listOf(outfit) + existing + existing + outfit } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt index e6fb33c0..e50c3c78 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt @@ -34,6 +34,7 @@ data class OutfitInfo( val equipped: Map, val createdAt: String, val updatedAt: String, + val blank: Boolean = false, ) sealed interface LibraryState { @@ -74,6 +75,7 @@ internal data class OutfitResponse( @SerialName("skin_id") val skinId: Int? = null, @SerialName("mojang_cape_id") val mojangCapeId: String? = null, @SerialName("particle_color") val particleColor: Int? = null, + val blank: Boolean = false, val equipped: Map = emptyMap(), @SerialName("created_at") val createdAt: String = "", @SerialName("updated_at") val updatedAt: String = "", @@ -88,6 +90,7 @@ internal data class OutfitResponse( equipped = equipped.filterKeys { it != BodySlot.Unknown }, createdAt = createdAt, updatedAt = updatedAt, + blank = blank, ) } From 36d4810a1fdc84d7d4cca9f2f2211af5074fa2e4 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 16:08:34 +0200 Subject: [PATCH 19/22] fix: drop outfits the server no longer has --- .../client/cosmetics/outfits/OutfitLibrary.kt | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt index d94989fa..48aa5e93 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -4,7 +4,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import io.ktor.client.plugins.ClientRequestException import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -116,7 +118,10 @@ object OutfitLibrary { outfits = outfits.filter { it.id != id } if (activeOutfitId == id) activeOutfitId = null } - .onFailure { LOGGER.error("Failed to delete outfit {}", id, it) } + .onFailure { + LOGGER.error("Failed to delete outfit {}", id, it) + forgetIfMissing(id, it) + } } fun failureMessage(error: Throwable): String = OutfitApi.failureMessage( @@ -160,7 +165,10 @@ object OutfitLibrary { return OutfitApi.send(HttpMethod.Patch, "/outfits/$id", body) .map { it.toInfo() } .onSuccess(::replace) - .onFailure { LOGGER.error("Failed to update outfit {}", id, it) } + .onFailure { + LOGGER.error("Failed to update outfit {}", id, it) + forgetIfMissing(id, it) + } } private suspend fun refreshLocked(): Result { @@ -192,7 +200,10 @@ object OutfitLibrary { private suspend fun applyLocked(id: Int): Result { val response = OutfitApi.send(HttpMethod.Post, "/outfits/$id/apply") - .onFailure { LOGGER.error("Failed to apply outfit {}", id, it) } + .onFailure { + LOGGER.error("Failed to apply outfit {}", id, it) + forgetIfMissing(id, it) + } .getOrElse { return Result.failure(it) } val outfit = response.outfit.toInfo() replace(outfit) @@ -263,6 +274,12 @@ object OutfitLibrary { Unit } + private fun forgetIfMissing(id: Int, error: Throwable) { + if ((error as? ClientRequestException)?.response?.status != HttpStatusCode.NotFound) return + outfits = outfits.filter { it.id != id } + if (activeOutfitId == id) activeOutfitId = null + } + private fun replace(outfit: OutfitInfo) { val existing = outfits outfits = if (existing.any { it.id == outfit.id }) { From 41beb77a38978fe730d491557ff494a856ba66c8 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 19:50:47 +0200 Subject: [PATCH 20/22] fix: read store prices from the sale object --- .../client/network/http/responses/Bundle.kt | 10 ++++---- .../network/http/responses/CosmeticStore.kt | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt index a89d5a01..1c54203b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt @@ -11,7 +11,7 @@ data class BundleInfo( @SerialName("created_at") val createdAt: String = "", @SerialName("asset_id") val assetId: Int? = null, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("store_product_id") val storeProductId: String? = null, ) { val purchasable: Boolean get() = !storeProductId.isNullOrBlank() @@ -20,11 +20,9 @@ data class BundleInfo( val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free - val finalPrice: Float? - get() = basePrice?.let { base -> - val rate = (discountRate ?: 0).coerceIn(0, 100) - base * (1f - rate / 100f) - } + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + + val finalPrice: Float? get() = sale?.price ?: basePrice } @Serializable diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt index c9472f5b..5dbd4735 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt @@ -3,6 +3,15 @@ package org.polyfrost.polyplus.client.network.http.responses import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class SaleInfo( + val price: Float = 0f, + val discount: Float = 0f, + val percent: Int = 0, + val name: String = "", + @SerialName("ends_at") val endsAt: String? = null, +) + @Serializable data class CosmeticTags( val colors: List = emptyList(), @@ -30,7 +39,7 @@ data class CosmeticStoreInfo( val collection: Int? = null, val type: CosmeticType = CosmeticType.Unknown, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("asset_id") val assetId: Int? = null, @SerialName("cover_asset_id") val coverAssetId: Int? = null, @SerialName("created_at") val createdAt: String = "", @@ -44,11 +53,9 @@ data class CosmeticStoreInfo( val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free - val finalPrice: Float? - get() = basePrice?.let { base -> - val rate = (discountRate ?: 0).coerceIn(0, 100) - base * (1f - rate / 100f) - } + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + + val finalPrice: Float? get() = sale?.price ?: basePrice } @Serializable @@ -66,7 +73,7 @@ data class CosmeticStoreView( val collection: Int? = null, val type: CosmeticType = CosmeticType.Unknown, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("asset_id") val assetId: Int? = null, @SerialName("cover_asset_id") val coverAssetId: Int? = null, @SerialName("created_at") val createdAt: String = "", @@ -75,6 +82,8 @@ data class CosmeticStoreView( ) { val purchasable: Boolean get() = !storeProductId.isNullOrBlank() + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + val variantList: List get() = variants?.takeIf { it.isNotEmpty() } ?: listOf(StoreVariant(id, name)) } From 1d863f7e7ed9de98e074f09e0e91a926ad79adde Mon Sep 17 00:00:00 2001 From: Deftu Date: Thu, 15 Oct 2026 14:24:31 +0200 Subject: [PATCH 21/22] fix: address wardrobe review feedback --- .../client/cosmetics/CosmeticSwatches.kt | 11 +++- .../polyplus/client/cosmetics/MojangAuth.kt | 25 ++++++++ .../client/cosmetics/MojangCapeService.kt | 63 ++++++++++--------- .../client/cosmetics/MojangSkinService.kt | 20 +----- .../client/cosmetics/outfits/OutfitLibrary.kt | 37 +++++++---- .../client/cosmetics/outfits/SkinLibrary.kt | 26 +++++--- .../polyplus/client/emotes/EmoteWheels.kt | 2 +- .../client/gui/wardrobe/WardrobeShell.kt | 14 +++-- 8 files changed, 127 insertions(+), 71 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt index 30e308f2..8c439553 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt @@ -45,10 +45,17 @@ object CosmeticSwatches { PolyPlusClient.SCOPE.launch { runSuspendCatching { CosmeticAssetCache.ensureCosmeticLoaded(id) - val built = withContext(Dispatchers.IO) { build(id) } ?: return@runSuspendCatching + val built = withContext(Dispatchers.IO) { build(id) } + if (built == null) { + requested.remove(id) + return@runSuspendCatching + } art[id] = built version++ - }.onFailure { LOGGER.debug("Could not build a swatch for cosmetic {}", id, it) } + }.onFailure { + requested.remove(id) + LOGGER.debug("Could not build a swatch for cosmetic {}", id, it) + } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt new file mode 100644 index 00000000..11a06b6f --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt @@ -0,0 +1,25 @@ +package org.polyfrost.polyplus.client.cosmetics + +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import net.minecraft.client.Minecraft + +internal object MojangAuth { + const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" + + fun accessToken(): String? = runCatching { + //? if > 1.8.9 { + Minecraft.getInstance().user.accessToken + //?} else { + /*Minecraft.getInstance().session.accessToken + *///?} + }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } + + fun HttpRequestBuilder.authorize(token: String) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt index bc8dba7b..5703b939 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt @@ -5,38 +5,38 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import io.ktor.client.call.body -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.accept import io.ktor.client.request.delete import io.ktor.client.request.get -import io.ktor.client.request.header import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsBytes import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import io.ktor.http.isSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import net.minecraft.client.Minecraft import net.minecraft.resources.Identifier import org.apache.logging.log4j.LogManager import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.PROFILE_URL +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.accessToken +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.authorize import org.polyfrost.polyplus.client.cosmetics.assets.RemoteTextures import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.io.ByteArrayInputStream import java.nio.file.Files import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO data class MojangCape(val id: String, val name: String, val url: String, val active: Boolean) { val textureHash: String get() = url.substringAfterLast('/') @@ -54,13 +54,15 @@ class MojangCapeException(message: String) : Exception(message) object MojangCapeService { private val LOGGER = LogManager.getLogger("polyplus/mojang-capes") - private const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" private const val ACTIVE_CAPE_URL = "$PROFILE_URL/capes/active" private const val SIGNED_OUT_MESSAGE = "Sign in with a Microsoft account to choose a Minecraft cape." + private const val UNREACHABLE_MESSAGE = "Couldn't reach Minecraft's servers." + private const val TEXTURE_RETRY_MS = 60_000L private val requestLock = Mutex() private val textures = ConcurrentHashMap() private val downloading = ConcurrentHashMap.newKeySet() + private val retryAt = ConcurrentHashMap() private val cacheDir = CosmeticAssetCache.baseDir.toPath().resolve("mojang") var state by mutableStateOf(MojangCapeState.Idle) @@ -116,11 +118,9 @@ object MojangCapeService { fun overridesLocalCape(uuid: UUID): Boolean = changedThisSession && state is MojangCapeState.Loaded && uuid == ClientPlatform.localPlayerUuid() - fun failureMessage(error: Throwable): String = when { - error is MojangCapeException -> error.message ?: SIGNED_OUT_MESSAGE - error is ClientRequestException && error.response.status == HttpStatusCode.TooManyRequests -> - "Minecraft is limiting cape changes right now. Try again in a minute." - else -> "Couldn't reach Minecraft's servers." + fun failureMessage(error: Throwable): String = when (error) { + is MojangCapeException -> error.message ?: SIGNED_OUT_MESSAGE + else -> UNREACHABLE_MESSAGE } private suspend fun mutate(call: suspend (String) -> HttpResponse): Result> = @@ -140,7 +140,7 @@ object MojangCapeService { val result = runSuspendCatching { val token = accessToken() ?: throw MojangCapeException(SIGNED_OUT_MESSAGE) val response = call(token) - if (response.status == HttpStatusCode.Unauthorized) throw MojangCapeException(SIGNED_OUT_MESSAGE) + ensureSuccess(response.status) response.body().capes.map { it.toCape() } } result.onSuccess { capes -> @@ -153,36 +153,43 @@ object MojangCapeService { return result } - private fun HttpRequestBuilder.authorize(token: String) { - accept(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $token") + private fun ensureSuccess(status: HttpStatusCode) { + if (status.isSuccess()) return + throw MojangCapeException( + when (status) { + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> SIGNED_OUT_MESSAGE + HttpStatusCode.TooManyRequests -> "Minecraft is limiting cape changes right now. Try again in a minute." + HttpStatusCode.BadRequest -> "Minecraft couldn't use that cape." + else -> UNREACHABLE_MESSAGE + }, + ) } - private fun accessToken(): String? = runCatching { - //? if > 1.8.9 { - Minecraft.getInstance().user.accessToken - //?} else { - /*Minecraft.getInstance().session.accessToken - *///?} - }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } - private fun load(cape: MojangCape) { val hash = cape.textureHash - if (hash.isBlank() || !downloading.add(hash)) return + if (hash.isBlank() || System.currentTimeMillis() < (retryAt[hash] ?: 0L) || !downloading.add(hash)) return PolyPlusClient.SCOPE.launch { + val file = cacheDir.resolve("$hash.png") runSuspendCatching { - val file = cacheDir.resolve("$hash.png") if (!Files.isRegularFile(file)) { - val bytes = PolyPlusClient.HTTP.get(cape.url).bodyAsBytes() + val response = PolyPlusClient.HTTP.get(cape.url) + if (!response.status.isSuccess()) throw MojangCapeException("Cape texture request returned ${response.status}") + val bytes = response.bodyAsBytes() withContext(Dispatchers.IO) { + if (ImageIO.read(ByteArrayInputStream(bytes)) == null) throw MojangCapeException("Cape texture isn't an image") Files.createDirectories(cacheDir) Files.write(file, bytes) } } RemoteTextures.register(texturePath(hash), file) textures[hash] = location(hash) + retryAt.remove(hash) textureVersion++ - }.onFailure { LOGGER.warn("Failed to load the texture for Minecraft cape {}", cape.name, it) } + }.onFailure { + LOGGER.warn("Failed to load the texture for Minecraft cape {}", cape.name, it) + retryAt[hash] = System.currentTimeMillis() + TEXTURE_RETRY_MS + withContext(Dispatchers.IO) { runCatching { Files.deleteIfExists(file) } } + } downloading.remove(hash) } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt index a6c7ecfc..232da9ab 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt @@ -1,12 +1,10 @@ package org.polyfrost.polyplus.client.cosmetics import io.ktor.client.call.body -import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.accept import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData import io.ktor.client.request.get -import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse @@ -22,9 +20,11 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -import net.minecraft.client.Minecraft import org.apache.logging.log4j.LogManager import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.PROFILE_URL +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.accessToken +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.authorize import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel import org.polyfrost.polyplus.client.utils.runSuspendCatching import java.nio.charset.StandardCharsets @@ -40,7 +40,6 @@ class MojangSkinException(message: String) : Exception(message) object MojangSkinService { private val LOGGER = LogManager.getLogger("polyplus/mojang-skins") - private const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" private const val SKINS_URL = "$PROFILE_URL/skins" private const val SESSION_PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile" private const val USERNAME_URL = "https://api.mojang.com/users/profiles/minecraft" @@ -206,19 +205,6 @@ object MojangSkinService { private fun secure(url: String): String = if (url.startsWith("http://textures.minecraft.net/")) "https://" + url.removePrefix("http://") else url - private fun HttpRequestBuilder.authorize(token: String) { - accept(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $token") - } - - private fun accessToken(): String? = runCatching { - //? if > 1.8.9 { - Minecraft.getInstance().user.accessToken - //?} else { - /*Minecraft.getInstance().session.accessToken - *///?} - }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } - @Serializable private data class SessionProfile(val properties: List = emptyList()) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt index 48aa5e93..2267b3fc 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -23,11 +23,13 @@ import org.polyfrost.polyplus.client.cosmetics.MojangSkinService import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.runSuspendCatching import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean object OutfitLibrary { private val LOGGER = LogManager.getLogger("polyplus/outfits") private val requestLock = Mutex() + private val adopting = AtomicBoolean(false) var outfits by mutableStateOf>(emptyList()) private set @@ -63,10 +65,18 @@ object OutfitLibrary { return requestLock.withLock { busy = true try { + if (MojangCapeService.state !is MojangCapeState.Loaded) { + MojangCapeService.refresh().onFailure { + return@withLock Result.failure( + OutfitException( + "Couldn't read your Minecraft cape, so the outfit wasn't saved (${MojangCapeService.failureMessage(it)}).", + ), + ) + } + } val skinId = SkinLibrary.recordCurrent() .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } .getOrNull()?.id - MojangCapeService.refreshIfNeeded() val capeId = MojangCapeService.activeCape()?.id val particleColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) val body = buildJsonObject { @@ -248,16 +258,21 @@ object OutfitLibrary { } private suspend fun adoptCurrentLook(outfit: OutfitInfo) { - val skinId = SkinLibrary.recordCurrent() - .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } - .getOrElse { return }?.id - MojangCapeService.refreshIfNeeded() - if (MojangCapeService.state !is MojangCapeState.Loaded) return - val capeId = MojangCapeService.activeCape()?.id - patch(outfit.id, buildJsonObject { - if (skinId != null) put("skin_id", skinId) - put("mojang_cape_id", capeId) - }) + if (!adopting.compareAndSet(false, true)) return + try { + MojangCapeService.refreshIfNeeded() + if (MojangCapeService.state !is MojangCapeState.Loaded) return + val skinId = SkinLibrary.recordCurrent() + .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } + .getOrElse { return }?.id + val capeId = MojangCapeService.activeCape()?.id + patch(outfit.id, buildJsonObject { + if (skinId != null) put("skin_id", skinId) + put("mojang_cape_id", capeId) + }) + } finally { + adopting.set(false) + } } private suspend fun applyMojangCape(capeId: String?): Result = runSuspendCatching { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt index 136bf442..ef559183 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt @@ -29,6 +29,7 @@ import org.polyfrost.polyplus.client.cosmetics.MojangSkinException import org.polyfrost.polyplus.client.cosmetics.MojangSkinService import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.File import java.util.UUID @@ -336,12 +337,14 @@ object SkinLibrary { private fun loadBitmap(skin: PlayerSkinInfo, key: String) { PolyPlusClient.SCOPE.launch { runSuspendCatching { - val cached = withContext(Dispatchers.IO) { readCache(skin.hash) } - val bytes = cached ?: download(skin.url).getOrThrow().also { fresh -> - withContext(Dispatchers.IO) { writeCache(skin.hash, fresh) } - } - val image = withContext(Dispatchers.IO) { ImageIO.read(ByteArrayInputStream(bytes)) } - ?: throw OutfitException(NOT_A_SKIN) + val image = withContext(Dispatchers.IO) { readCache(skin.hash) } + ?: download(skin.url).getOrThrow().let { fresh -> + withContext(Dispatchers.IO) { + val decoded = decode(fresh) ?: throw OutfitException(NOT_A_SKIN) + writeCache(skin.hash, fresh) + decoded + } + } bitmaps[key] = image.toComposeImageBitmap() bitmapVersion++ }.onFailure { @@ -355,8 +358,15 @@ object SkinLibrary { private fun cacheFile(hash: String): File? = hash.lowercase().takeIf { HASH_PATTERN.matches(it) }?.let { File(cacheDir, "$it.png") } - private fun readCache(hash: String): ByteArray? = - cacheFile(hash)?.takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() } + private fun readCache(hash: String): BufferedImage? { + val file = cacheFile(hash)?.takeIf { it.isFile } ?: return null + val image = runCatching { file.readBytes() }.getOrNull()?.let(::decode) + if (image == null) runCatching { file.delete() } + return image + } + + private fun decode(bytes: ByteArray): BufferedImage? = + runCatching { ImageIO.read(ByteArrayInputStream(bytes)) }.getOrNull() private fun writeCache(hash: String, bytes: ByteArray) { val file = cacheFile(hash) ?: return diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt index d1732a04..4f84a4af 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt @@ -36,7 +36,7 @@ object EmoteWheels { if (slot !in 0 until SLOT_COUNT) return val wheels = materialized().toMutableList() while (wheels.size <= index) wheels += List(SLOT_COUNT) { null } - val next = wheels[index].toMutableList() + val next = MutableList(SLOT_COUNT) { wheels[index].getOrNull(it) } if (emoteId != null) { for (i in next.indices) if (next[i] == emoteId) next[i] = null } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index f565ef76..4ad9a6aa 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -109,6 +109,14 @@ internal fun WardrobeShell( val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort, emoteKeybindWarning, outfitKey, skinKey) { buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable, emoteKeybindWarning) } + val coverByVariant = remember(store.byType, store.popular, store.newest) { + val covers = HashMap() + for (info in store.all) { + val cover = info.coverAssetId ?: continue + for (variant in info.variantList) covers.putIfAbsent(variant.id, cover) + } + covers + } val anchors = remember(entries) { entries.mapIndexedNotNull { index, entry -> (entry as? WardrobeEntry.Divider)?.takeIf { it.anchor }?.let { it.label to index } @@ -195,9 +203,7 @@ internal fun WardrobeShell( storeVariantPicks = storeVariantPicks, mojangBusy = MojangCapeService.busy, modifier = Modifier.weight(1f).fillMaxWidth(), - coverFor = { item -> - store.all.firstOrNull { info -> info.variantList.any { v -> item.variants.any { it.id == v.id } } }?.coverAssetId - }, + coverFor = { item -> item.variants.firstNotNullOfOrNull { coverByVariant[it.id] } }, onOwned = { item -> if (wheelMode && item.type == CosmeticType.Emote) { val wheel = EmoteWheels.activeIndex @@ -318,7 +324,7 @@ internal fun WardrobeShell( ) wheelMode -> EmoteWheelEditor( selectedSlot = wheelSlot, - coverForEmote = { id -> store.all.firstOrNull { info -> info.variantList.any { it.id == id } }?.coverAssetId }, + coverForEmote = { id -> coverByVariant[id] }, onSelectSlot = { wheelSlot = it }, modifier = Modifier.fillMaxSize(), ) From 5bce3c38b3a6f9df082e0057bd42382373f1809e Mon Sep 17 00:00:00 2001 From: Deftu Date: Thu, 15 Oct 2026 18:12:15 +0200 Subject: [PATCH 22/22] fix: harden outfit adoption, cape state and store loading --- .../polyplus/client/PolyPlusConfig.kt | 2 +- .../polyplus/client/cosmetics/CapeResolver.kt | 4 +- .../client/cosmetics/CosmeticCatalog.kt | 4 +- .../client/cosmetics/CosmeticLoadProgress.kt | 2 + .../client/cosmetics/CosmeticSwatches.kt | 15 +- .../polyplus/client/cosmetics/CosmeticSync.kt | 15 +- .../client/cosmetics/MojangCapeService.kt | 51 +++- .../client/cosmetics/MojangSkinService.kt | 20 +- .../client/cosmetics/outfits/OutfitLibrary.kt | 181 +++++++++---- .../client/cosmetics/outfits/OutfitModels.kt | 1 + .../client/cosmetics/outfits/SkinLibrary.kt | 241 ++++++++++++++---- .../polyplus/client/emotes/EmoteWheels.kt | 5 +- .../polyplus/client/gui/EmoteWheelScreen.kt | 22 +- .../client/gui/PolyPlusCosmeticsScreen.kt | 32 ++- .../client/gui/preview/PlayerPreview.kt | 8 +- .../gui/preview/PlayerPreviewRenderer.kt | 9 +- .../client/gui/wardrobe/AuraColorPicker.kt | 23 +- .../client/gui/wardrobe/WardrobeActions.kt | 84 ++++-- .../client/gui/wardrobe/WardrobeGrid.kt | 2 +- .../client/gui/wardrobe/WardrobeHistory.kt | 9 +- .../client/gui/wardrobe/WardrobeLibrary.kt | 49 ++-- .../client/gui/wardrobe/WardrobeLoading.kt | 15 +- .../client/gui/wardrobe/WardrobeModels.kt | 7 +- .../client/gui/wardrobe/WardrobePreview.kt | 13 +- .../client/gui/wardrobe/WardrobeShell.kt | 22 +- .../client/gui/wardrobe/WardrobeStoreData.kt | 106 ++++++-- .../gui/wardrobe/WardrobeStorePanels.kt | 4 +- .../client/gui/wardrobe/WardrobeThumbnails.kt | 6 - .../gui/wardrobe/WardrobeWheelEditor.kt | 8 +- 29 files changed, 708 insertions(+), 252 deletions(-) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt index d8003b0c..b846b524 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt @@ -232,7 +232,7 @@ object PolyPlusConfig : Config("${PolyPlusConstants.ID}.json", "${PolyPlusConsta ) var wardrobeKeybind = OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { state -> if (state) { - PolyPlusOneConfigIntegration.openCosmetics() + PolyPlusOneConfigIntegration.openCosmeticsFromKeybind() } true } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt index 67ad3262..57e9ec5d 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt @@ -55,12 +55,12 @@ object CapeResolver { if (CosmeticAssetCache.getCapeTexture(playerId) != null) return CapeDecision.POLYPLUS val mojangKnown = MojangCapeService.overridesLocalCape(playerId) || - (foreign && local && MojangCapeService.state is MojangCapeState.Loaded) + (foreign && local && MojangCapeService.isLoaded()) if (mojangKnown) { if (MojangCapeService.localCapeHidden()) return CapeDecision.NONE if (MojangCapeService.localCapeTexture() != null) return CapeDecision.MOJANG } - return if (foreign) CapeDecision.VANILLA else CapeDecision.KEEP + return if (foreign && local) CapeDecision.VANILLA else CapeDecision.KEEP } //? if > 1.8.9 { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt index e31d1244..dc147ebe 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt @@ -102,7 +102,9 @@ object CosmeticCatalog { }.bodyOrThrow() }.onFailure { reportCatalogFailure(it) } .getOrElse { - CosmeticLoadProgress.fail(it.message ?: it::class.java.simpleName) + CosmeticLoadProgress.fail( + if (isRuntimeMismatch(it)) CosmeticLoadProgress.OUTDATED_RUNTIME else it.message ?: it::class.java.simpleName, + ) return } CosmeticLoadProgress.onCatalogFetched() diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt index bb8b7d9b..df703192 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt @@ -9,6 +9,8 @@ object CosmeticLoadProgress { private const val ASSET_WEIGHT = 0.75f + const val OUTDATED_RUNTIME = "Fabric Language Kotlin is out of date" + data class Snapshot( val started: Boolean, val ready: Boolean, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt index 8c439553..8e08b2e2 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.apache.logging.log4j.LogManager @@ -27,6 +28,8 @@ object CosmeticSwatches { private val art = ConcurrentHashMap() private val requested = ConcurrentHashMap.newKeySet() + private const val RETRY_MS = 60_000L + private const val CAPE_TEXTURE_WIDTH = 64 private const val LEGACY_CAPE_TEXTURE_WIDTH = 22 private const val MAX_STRIPES = 3 @@ -47,18 +50,26 @@ object CosmeticSwatches { CosmeticAssetCache.ensureCosmeticLoaded(id) val built = withContext(Dispatchers.IO) { build(id) } if (built == null) { - requested.remove(id) + retryLater(id) return@runSuspendCatching } art[id] = built version++ }.onFailure { - requested.remove(id) + retryLater(id) LOGGER.debug("Could not build a swatch for cosmetic {}", id, it) } } } + private fun retryLater(id: Int) { + PolyPlusClient.SCOPE.launch { + delay(RETRY_MS) + requested.remove(id) + version++ + } + } + private fun build(id: Int): SwatchArt? { val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null val file = CosmeticAssetCache.primaryTextureFile(id) ?: return null diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt index 5a6ddb6e..1c067099 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt @@ -34,7 +34,9 @@ import org.polyfrost.polyplus.events.WebSocketMessage import java.time.Duration import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.delay import kotlinx.coroutines.launch object CosmeticSync { @@ -54,6 +56,9 @@ object CosmeticSync { @Volatile private var capWarned = false + private const val LOCAL_RESYNC_DELAY_MS = 500L + private val localResyncPending = AtomicBoolean(false) + //? if >= 1.21.1 || = 1.8.9 { private const val RECONCILE_INTERVAL_TICKS = 20 private var reconcileTicks = 0 @@ -300,9 +305,15 @@ object CosmeticSync { CosmeticCatalog.applyRemoteEquippedSlot(uuid, packet.slot, packet.cosmeticId) applyActiveToPlayer(uuid, packet.cosmeticId?.let(::listOf).orEmpty()) if (uuid == ClientPlatform.localPlayerUuid() && - CosmeticCatalog.localEquipped().equipped[packet.slot] != packet.cosmeticId + packet.slot != BodySlot.Unknown && + CosmeticCatalog.localEquipped().equipped[packet.slot] != packet.cosmeticId && + localResyncPending.compareAndSet(false, true) ) { - PolyPlusClient.SCOPE.launch { CosmeticService.syncLocalActive() } + PolyPlusClient.SCOPE.launch { + delay(LOCAL_RESYNC_DELAY_MS) + localResyncPending.set(false) + CosmeticService.syncLocalActive() + } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt index 5703b939..08d335fb 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt @@ -15,7 +15,10 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -77,13 +80,32 @@ object MojangCapeService { @Volatile private var changedThisSession = false + @Volatile + private var loadedFor: UUID? = null + + @Volatile + private var refreshJob: Deferred>>? = null + suspend fun refreshIfNeeded() { - if (state is MojangCapeState.Idle) refresh() + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (stale || state is MojangCapeState.Idle || state is MojangCapeState.Unavailable) refresh() } - suspend fun refresh(): Result> = requestLock.withLock { - if (state !is MojangCapeState.Loaded) state = MojangCapeState.Loading - request { token -> PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } } + suspend fun refresh(): Result> { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } ?: PolyPlusClient.SCOPE.async { + requestLock.withLock { + val owner = ClientPlatform.localPlayerUuid() + if (state !is MojangCapeState.Loaded || loadedFor != owner) state = MojangCapeState.Loading + try { + request { token -> PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } } + } finally { + if (state is MojangCapeState.Loading) state = MojangCapeState.Idle + } + } + }.also { refreshJob = it } + } + return job.await() } suspend fun select(capeId: String): Result> = mutate { token -> @@ -98,7 +120,15 @@ object MojangCapeService { PolyPlusClient.HTTP.delete(ACTIVE_CAPE_URL) { authorize(token) } } - fun activeCape(): MojangCape? = (state as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.active } + fun isLoaded(): Boolean = localCapes() != null + + fun activeCape(): MojangCape? = localCapes()?.firstOrNull { it.active } + + private fun localCapes(): List? { + val capes = (state as? MojangCapeState.Loaded)?.capes ?: return null + val owner = loadedFor ?: return null + return capes.takeIf { owner == runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() } + } fun texture(cape: MojangCape): Identifier? { val hash = cape.textureHash @@ -112,11 +142,11 @@ object MojangCapeService { fun localCapeTexture(): Identifier? = activeCape()?.let(::texture) @JvmStatic - fun localCapeHidden(): Boolean = state is MojangCapeState.Loaded && activeCape() == null + fun localCapeHidden(): Boolean = localCapes()?.none { it.active } == true @JvmStatic fun overridesLocalCape(uuid: UUID): Boolean = - changedThisSession && state is MojangCapeState.Loaded && uuid == ClientPlatform.localPlayerUuid() + changedThisSession && state is MojangCapeState.Loaded && uuid == loadedFor && uuid == ClientPlatform.localPlayerUuid() fun failureMessage(error: Throwable): String = when (error) { is MojangCapeException -> error.message ?: SIGNED_OUT_MESSAGE @@ -137,6 +167,7 @@ object MojangCapeService { } private suspend fun request(call: suspend (String) -> HttpResponse): Result> { + val owner = ClientPlatform.localPlayerUuid() val result = runSuspendCatching { val token = accessToken() ?: throw MojangCapeException(SIGNED_OUT_MESSAGE) val response = call(token) @@ -144,6 +175,8 @@ object MojangCapeService { response.body().capes.map { it.toCape() } } result.onSuccess { capes -> + if (loadedFor != owner) changedThisSession = false + loadedFor = owner state = MojangCapeState.Loaded(capes) capes.forEach(::texture) }.onFailure { error -> @@ -189,6 +222,10 @@ object MojangCapeService { LOGGER.warn("Failed to load the texture for Minecraft cape {}", cape.name, it) retryAt[hash] = System.currentTimeMillis() + TEXTURE_RETRY_MS withContext(Dispatchers.IO) { runCatching { Files.deleteIfExists(file) } } + PolyPlusClient.SCOPE.launch { + delay(TEXTURE_RETRY_MS) + textureVersion++ + } } downloading.remove(hash) } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt index 232da9ab..d7ace5b3 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt @@ -89,7 +89,7 @@ object MojangSkinService { } suspend fun activeSkin(): Result = runSuspendCatching { - val token = accessToken() ?: return@runSuspendCatching null + val token = accessToken() ?: throw MojangSkinException(SIGNED_OUT_MESSAGE) readProfile(token) } @@ -98,7 +98,13 @@ object MojangSkinService { val response = PolyPlusClient.HTTP.get("$SESSION_PROFILE_URL/$id") { accept(ContentType.Application.Json) } - if (response.status != HttpStatusCode.OK) return@runSuspendCatching null + when (response.status) { + HttpStatusCode.OK -> Unit + HttpStatusCode.NoContent, HttpStatusCode.NotFound -> return@runSuspendCatching null + HttpStatusCode.TooManyRequests -> + throw MojangSkinException("Minecraft is limiting lookups right now. Try again in a minute.") + else -> throw MojangSkinException(UNREACHABLE_MESSAGE) + } val texturesValue = response.body().properties .firstOrNull { it.name == "textures" } ?.value @@ -139,7 +145,7 @@ object MojangSkinService { private suspend fun change(call: suspend (String) -> HttpResponse): Result = requestLock.withLock { runSuspendCatching { val token = accessToken() ?: throw MojangSkinException(SIGNED_OUT_MESSAGE) - val previous = knownTexture + val previous = knownTexture ?: runSuspendCatching { readProfile(token) }.getOrNull()?.textureHash val response = call(token) ensureSkinChanged(response) val reported = runSuspendCatching { response.body() }.getOrNull()?.let(::activeSkin) @@ -153,16 +159,12 @@ object MojangSkinService { } private suspend fun readBack(token: String, previous: String?): MojangSkin? { - var latest: MojangSkin? = null repeat(READBACK_ATTEMPTS) { delay(READBACK_DELAY_MS) val skin = runSuspendCatching { readProfile(token) }.getOrNull() - if (skin != null) { - latest = skin - if (skin.textureHash != previous) return skin - } + if (skin != null && skin.textureHash != previous) return skin } - return latest + return null } private suspend fun readProfile(token: String): MojangSkin? { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt index 2267b3fc..757dc64f 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.setValue import io.ktor.client.plugins.ClientRequestException import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -18,11 +20,11 @@ import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.cosmetics.MojangCapeService -import org.polyfrost.polyplus.client.cosmetics.MojangCapeState import org.polyfrost.polyplus.client.cosmetics.MojangSkinService import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.runSuspendCatching import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean object OutfitLibrary { @@ -30,6 +32,8 @@ object OutfitLibrary { private val requestLock = Mutex() private val adopting = AtomicBoolean(false) + private val adoptionAttempts = ConcurrentHashMap, Long>() + private const val ADOPTION_RETRY_MS = 10 * 60_000L var outfits by mutableStateOf>(emptyList()) private set @@ -52,31 +56,44 @@ object OutfitLibrary { @Volatile private var loadedFor: UUID? = null - suspend fun refreshIfNeeded() { + @Volatile + private var refreshJob: Deferred>? = null + + suspend fun refreshIfNeeded(reload: Boolean = false) { val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() - if (stale || state is LibraryState.Idle || state is LibraryState.Unavailable) refresh() + if (reload || stale || state is LibraryState.Idle || state is LibraryState.Unavailable) refresh() SkinLibrary.recordInBackground() } - suspend fun refresh(): Result = requestLock.withLock { refreshLocked() } + suspend fun refresh(): Result { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } + ?: PolyPlusClient.SCOPE.async { requestLock.withLock { refreshLocked() } }.also { refreshJob = it } + } + return job.await() + } suspend fun createFromCurrent(): Result { if (!OutfitApi.allowed()) return unavailable() return requestLock.withLock { busy = true try { - if (MojangCapeService.state !is MojangCapeState.Loaded) { - MojangCapeService.refresh().onFailure { + MojangCapeService.refresh().onFailure { + return@withLock Result.failure( + OutfitException( + "Couldn't read your Minecraft cape, so the outfit wasn't saved (${MojangCapeService.failureMessage(it)}).", + ), + ) + } + val skinId = SkinLibrary.recordCurrent() + .getOrElse { + LOGGER.warn("Could not add the current Minecraft skin to the library", it) return@withLock Result.failure( OutfitException( - "Couldn't read your Minecraft cape, so the outfit wasn't saved (${MojangCapeService.failureMessage(it)}).", + "Couldn't save your current skin, so the outfit wasn't saved (${SkinLibrary.failureMessage(it)}).", ), ) - } - } - val skinId = SkinLibrary.recordCurrent() - .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } - .getOrNull()?.id + }?.id val capeId = MojangCapeService.activeCape()?.id val particleColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) val body = buildJsonObject { @@ -144,32 +161,57 @@ object OutfitLibrary { internal fun activeOutfit(): OutfitInfo? = activeOutfitId?.let { id -> outfits.firstOrNull { it.id == id } } internal fun onMojangCapeChanged(capeId: String?) { - val outfit = liveSyncTarget() ?: return - if (outfit.mojangCapeId == capeId) return - PolyPlusClient.SCOPE.launch { patch(outfit.id, buildJsonObject { put("mojang_cape_id", capeId) }) } + if (applying || !OutfitApi.allowed()) return + PolyPlusClient.SCOPE.launch { + val outfit = liveSyncTarget() ?: return@launch + if (outfit.blank) { + adoptLive(outfit).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + return@launch + } + if (outfit.mojangCapeId == capeId) return@launch + patch(outfit.id, buildJsonObject { put("mojang_cape_id", capeId) }) + } } internal fun onParticleColorChanged(color: Int?) { - val outfit = liveSyncTarget() ?: return - if (outfit.particleColor == color) return - PolyPlusClient.SCOPE.launch { patch(outfit.id, buildJsonObject { put("particle_color", color) }) } + val outfit = activeOutfit() ?: return + if (outfit.particleColor != color) replace(outfit.copy(particleColor = color)) } internal suspend fun onSkinWorn(skinId: Int): Result { - val id = activeOutfitId ?: return Result.success(Unit) - if (activeOutfit()?.skinId == skinId) return Result.success(Unit) - return patch(id, buildJsonObject { put("skin_id", skinId) }).map { } + val outfit = liveSyncTarget() ?: return Result.success(Unit) + if (outfit.blank) { + adoptLive(outfit, skinId).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + return Result.success(Unit) + } + if (outfit.skinId == skinId) return Result.success(Unit) + return patch(outfit.id, buildJsonObject { put("skin_id", skinId) }).map { } } internal fun onSkinDeleted(skinId: Int) { outfits = outfits.map { if (it.skinId == skinId) it.copy(skinId = null) else it } } - private fun liveSyncTarget(): OutfitInfo? { - if (applying || !OutfitApi.allowed()) return null + private suspend fun liveSyncTarget(): OutfitInfo? { + if (applying || !OutfitApi.allowed() || state !is LibraryState.Loaded) return null + if (refresh().isFailure || applying) return null return activeOutfit() } + private suspend fun adoptLive(target: OutfitInfo, wornSkinId: Int? = null): Result = requestLock.withLock { + val outfit = activeOutfit()?.takeIf { it.id == target.id } ?: return@withLock Result.success(Unit) + when { + outfit.blank -> adoptCurrentLook(outfit, wornSkinId = wornSkinId) + wornSkinId != null && outfit.skinId != wornSkinId -> + patch(outfit.id, buildJsonObject { put("skin_id", wornSkinId) }).map { } + else -> Result.success(Unit) + } + } + private suspend fun patch(id: Int, body: JsonObject): Result { if (!OutfitApi.allowed()) return unavailable() return OutfitApi.send(HttpMethod.Patch, "/outfits/$id", body) @@ -190,22 +232,34 @@ object OutfitLibrary { } val owner = ClientPlatform.localPlayerUuid() if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading - return OutfitApi.send(HttpMethod.Get, "/outfits") - .onSuccess { response -> - outfits = response.outfits.map { it.toInfo() } - activeOutfitId = response.activeOutfitId - limit = response.limit - loadedFor = owner - state = LibraryState.Loaded - activeOutfit()?.takeIf { it.blank }?.let { blank -> - PolyPlusClient.SCOPE.launch { adoptCurrentLook(blank) } + try { + return OutfitApi.send(HttpMethod.Get, "/outfits") + .onSuccess { response -> + outfits = response.outfits.map { it.toInfo() } + activeOutfitId = response.activeOutfitId + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + activeOutfit()?.takeIf { it.blank && claimBackgroundAdoption(owner, it.id) }?.let { target -> + PolyPlusClient.SCOPE.launch { + requestLock.withLock { + activeOutfit()?.takeIf { it.id == target.id && it.blank }?.let { outfit -> + adoptCurrentLook(outfit, background = true).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + } + } + } + } } - } - .onFailure { error -> - LOGGER.error("Failed to fetch outfits", error) - if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) - } - .map { } + .onFailure { error -> + LOGGER.error("Failed to fetch outfits", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } finally { + if (state is LibraryState.Loading) state = LibraryState.Idle + } } private suspend fun applyLocked(id: Int): Result { @@ -221,10 +275,13 @@ object OutfitLibrary { response.skin?.let { skin -> SkinLibrary.remember(skin.toInfo()) } val problems = mutableListOf() - if (outfit.blank) { - adoptCurrentLook(outfit) + if (response.adopted) { + adoptCurrentLook(outfit).onFailure { + problems += "your current skin and cape weren't saved to it (${it.message})" + } } - response.skin?.toInfo()?.let { skin -> + val keepLook = response.adopted || outfit.blank + response.skin?.toInfo()?.takeIf { !keepLook }?.let { skin -> val current = MojangSkinService.activeSkin().getOrNull() if (current == null || current.textureHash != skin.hash || current.model != skin.model) { MojangSkinService.setSkin(skin.url, skin.model) @@ -234,7 +291,7 @@ object OutfitLibrary { } } } - if (!outfit.blank) { + if (!keepLook) { applyMojangCape(outfit.mojangCapeId).onFailure { problems += "your Minecraft cape wasn't changed (${MojangCapeService.failureMessage(it)})" } @@ -257,27 +314,45 @@ object OutfitLibrary { return Result.failure(OutfitException(message)) } - private suspend fun adoptCurrentLook(outfit: OutfitInfo) { - if (!adopting.compareAndSet(false, true)) return + private suspend fun adoptCurrentLook( + outfit: OutfitInfo, + background: Boolean = false, + wornSkinId: Int? = null, + ): Result { + if (!adopting.compareAndSet(false, true)) return Result.success(Unit) try { - MojangCapeService.refreshIfNeeded() - if (MojangCapeService.state !is MojangCapeState.Loaded) return - val skinId = SkinLibrary.recordCurrent() - .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } - .getOrElse { return }?.id + MojangCapeService.refresh().onFailure { + return Result.failure(OutfitException(MojangCapeService.failureMessage(it))) + } + if (!MojangCapeService.isLoaded()) return Result.failure(OutfitException("Couldn't read your Minecraft cape.")) val capeId = MojangCapeService.activeCape()?.id + val skinId = wornSkinId ?: SkinLibrary.recordCurrent(background) + .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } + .getOrElse { return Result.failure(OutfitException(SkinLibrary.failureMessage(it))) } + ?.id patch(outfit.id, buildJsonObject { - if (skinId != null) put("skin_id", skinId) + put("skin_id", skinId) put("mojang_cape_id", capeId) - }) + put("blank", false) + }).onFailure { return Result.failure(OutfitException(failureMessage(it))) } + return Result.success(Unit) } finally { adopting.set(false) } } + private fun claimBackgroundAdoption(owner: UUID, id: Int): Boolean { + val now = System.currentTimeMillis() + val key = owner to id + val last = adoptionAttempts[key] + if (last != null && now - last < ADOPTION_RETRY_MS) return false + adoptionAttempts[key] = now + return true + } + private suspend fun applyMojangCape(capeId: String?): Result = runSuspendCatching { - MojangCapeService.refreshIfNeeded() - val loaded = MojangCapeService.state is MojangCapeState.Loaded + MojangCapeService.refresh() + val loaded = MojangCapeService.isLoaded() val current = MojangCapeService.activeCape()?.id if (capeId == null) { if (!loaded || current == null) return@runSuspendCatching diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt index e50c3c78..5180f335 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt @@ -111,6 +111,7 @@ internal data class SkinListResponse( internal data class ApplyOutfitResponse( val outfit: OutfitResponse, val skin: SkinResponse? = null, + val adopted: Boolean = false, ) internal const val LIBRARY_LIMIT = 128 diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt index ef559183..6987f3ad 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.get import io.ktor.client.statement.bodyAsBytes import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode import io.ktor.http.contentLength import io.ktor.http.isSuccess import kotlinx.coroutines.Deferred @@ -44,6 +46,10 @@ object SkinLibrary { private const val MAX_DOWNLOAD_BYTES = 1024L * 1024L private const val RECORD_INTERVAL_MS = 30_000L private const val NOT_A_SKIN = "That image isn't a Minecraft skin." + internal const val TOO_LARGE = "That skin file is too large. Skins can be at most 64 KB." + internal const val MAX_FILE_BYTES = 64L * 1024L + private const val BITMAP_RETRY_MS = 60_000L + private const val RECORD_FAILURE_BACKOFF_MS = 10 * 60_000L private const val DOWNLOAD_FAILED = "Couldn't download that image." private val USERNAME_PATTERN = Regex("^[A-Za-z0-9_]{1,16}$") private val HASH_PATTERN = Regex("^[0-9a-f]{32,64}$") @@ -55,7 +61,7 @@ object SkinLibrary { private val cacheDir = File("${PolyPlusConstants.NAME}/skins") private val bitmaps = ConcurrentHashMap() private val loading = ConcurrentHashMap.newKeySet() - private val failed = ConcurrentHashMap.newKeySet() + private val failed = ConcurrentHashMap() private val signedInFor = AtomicReference(null) var skins by mutableStateOf>(emptyList()) @@ -79,6 +85,20 @@ object SkinLibrary { @Volatile private var recordJob: Deferred>? = null + @Volatile + private var recordJobBackground = false + + @Volatile + private var refreshJob: Deferred>? = null + + private data class RecordFailure(val at: Long, val conflict: Boolean) + + private class SkinRecordSkipped(reason: String) : Exception(reason) + + private val recordFailures = ConcurrentHashMap, RecordFailure>() + + private var wornHash by mutableStateOf?>(null) + @Volatile private var lastRecordOwner: UUID? = null @@ -94,9 +114,15 @@ object SkinLibrary { } } - suspend fun refresh(): Result = refreshLocked().also { recordInBackground() } + suspend fun refresh(): Result { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } ?: PolyPlusClient.SCOPE.async { refreshLocked() }.also { refreshJob = it } + } + return job.await().also { recordInBackground() } + } - suspend fun recordCurrent(): Result = recordShared(fresh = false) + suspend fun recordCurrent(background: Boolean = false): Result = + recordShared(fresh = false, background = background) suspend fun addFromBytes(png: ByteArray, name: String?, model: SkinModel): Result { if (!OutfitApi.allowed()) return unavailable() @@ -142,6 +168,7 @@ object SkinLibrary { try { MojangSkinService.setSkin(skin.url, skin.model) .getOrElse { return Result.failure(mojangFailure(it)) } + wornHash = ClientPlatform.localPlayerUuid() to skin.hash val patched = OutfitLibrary.onSkinWorn(id) recordInBackground(force = true) patched.onFailure { @@ -162,25 +189,49 @@ object SkinLibrary { suspend fun setFavorite(id: Int, favorite: Boolean): Result = patch(id, buildJsonObject { put("favorite", favorite) }) - suspend fun setModel(id: Int, model: SkinModel): Result = - patch(id, buildJsonObject { put("model", model.serializedName) }) + suspend fun setModel(id: Int, model: SkinModel): Result { + val worn = wornSkinId() == id + val updated = patch(id, buildJsonObject { put("model", model.serializedName) }) + .getOrElse { return Result.failure(it) } + if (!worn) return Result.success(updated) + busy = true + try { + MojangSkinService.setSkin(updated.url, updated.model).getOrElse { + return Result.failure( + OutfitException("${updated.name} was updated, but your Minecraft skin wasn't changed (${MojangSkinService.failureMessage(it)})."), + ) + } + recordInBackground(force = true) + } finally { + busy = false + } + return Result.success(updated) + } suspend fun delete(id: Int): Result { if (!OutfitApi.allowed()) return unavailable() return OutfitApi.send(HttpMethod.Delete, "/skins/$id") .onSuccess { - skins = skins.filter { it.id != id } - OutfitLibrary.onSkinDeleted(id) + forget(id) + val owner = ClientPlatform.localPlayerUuid() + recordFailures.entries.removeIf { it.key.first == owner && it.value.conflict } + } + .onFailure { + LOGGER.error("Failed to delete skin {}", id, it) + forgetIfMissing(id, it) } - .onFailure { LOGGER.error("Failed to delete skin {}", id, it) } } - fun wornSkinId(): Int? = OutfitLibrary.activeOutfit()?.skinId + fun wornSkinId(): Int? { + val owner = runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() + val hash = wornHash?.takeIf { it.first == owner }?.second ?: return OutfitLibrary.activeOutfit()?.skinId + return skins.firstOrNull { it.hash == hash }?.id + } fun bitmap(skin: PlayerSkinInfo): ImageBitmap? { val key = skin.hash.ifBlank { skin.url } bitmaps[key]?.let { return it } - if (key !in failed && loading.add(key)) loadBitmap(skin, key) + if (System.currentTimeMillis() >= (failed[key] ?: 0L) && loading.add(key)) loadBitmap(skin, key) return null } @@ -198,41 +249,51 @@ object SkinLibrary { internal fun onSignedIn(profileId: UUID) { if (signedInFor.getAndSet(profileId) == profileId) return - recordInBackground(force = true) + recordInBackground(force = true, background = true) } - internal fun recordInBackground(force: Boolean = false) { + internal fun recordInBackground(force: Boolean = false, background: Boolean = !force) { if (!OutfitApi.allowed()) return val owner = runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() ?: return if (!force && lastRecordOwner == owner && System.currentTimeMillis() - lastRecordAt < RECORD_INTERVAL_MS) return PolyPlusClient.SCOPE.launch { - recordShared(fresh = force).onFailure { LOGGER.warn("Could not save the current Minecraft skin", it) } + recordShared(fresh = force, background = background).onFailure { + if (it is SkinRecordSkipped) { + LOGGER.debug("Skipped saving the current Minecraft skin: {}", it.message) + } else { + LOGGER.warn("Could not save the current Minecraft skin", it) + } + } } } - private suspend fun recordShared(fresh: Boolean): Result { + private suspend fun recordShared(fresh: Boolean, background: Boolean): Result { if (!OutfitApi.allowed()) return OutfitApi.privacyFailure() val job = recordJobLock.withLock { val running = recordJob?.takeIf { it.isActive } - if (running != null && !fresh) { + if (running != null && !fresh && (background || !recordJobBackground)) { running } else { PolyPlusClient.SCOPE.async { running?.join() - recordLock.withLock { recordActive() } - }.also { recordJob = it } + recordLock.withLock { recordActive(background) } + }.also { + recordJob = it + recordJobBackground = background + } } } return job.await() } - private suspend fun recordActive(): Result { + private suspend fun recordActive(background: Boolean): Result { val owner = ClientPlatform.localPlayerUuid() try { val current = MojangSkinService.activeSkin() .getOrElse { return Result.failure(mojangFailure(it)) } ?: return Result.success(null) - return record(current, null, owner) + current.textureHash?.let { wornHash = owner to it } + return record(current, null, owner, background, correctModel = true) } finally { lastRecordOwner = owner lastRecordAt = System.currentTimeMillis() @@ -247,13 +308,19 @@ object SkinLibrary { try { val recorded = recordLock.withLock { val owner = ClientPlatform.localPlayerUuid() + if (loadedFor == owner && skins.size >= limit) { + return Result.failure(OutfitException("Your skin library is full ($limit skins). Delete one to make room.")) + } val worn = upload().getOrElse { return Result.failure(mojangFailure(it)) } ?: return Result.failure( OutfitException("Your skin changed, but Minecraft hasn't reported it yet. Try again in a moment."), ) - record(worn, name, owner).also { + worn.textureHash?.let { wornHash = owner to it } + record(worn, name, owner, correctModel = true).also { lastRecordOwner = owner lastRecordAt = System.currentTimeMillis() + }.recoverCatching { + throw OutfitException("Your skin changed, but it couldn't be saved to your library (${failureMessage(it)}).") } } recorded.onSuccess { skin -> @@ -266,11 +333,23 @@ object SkinLibrary { } } - private suspend fun record(skin: MojangSkin, name: String?, owner: UUID): Result { + private suspend fun record( + skin: MojangSkin, + name: String?, + owner: UUID, + background: Boolean = false, + correctModel: Boolean = false, + ): Result { val hash = skin.textureHash ?: return Result.failure(OutfitException("Minecraft didn't report a usable skin texture.")) - if (name == null && loadedFor == owner) { - skins.firstOrNull { it.hash == hash }?.let { return Result.success(it) } + val key = owner to hash + if (background) { + recordFailures[key]?.let { failure -> + if (System.currentTimeMillis() - failure.at < RECORD_FAILURE_BACKOFF_MS) { + val reason = if (failure.conflict) "the skin library is full" else "the last attempt failed recently" + return Result.failure(SkinRecordSkipped(reason)) + } + } } val label = name?.trim()?.takeIf { it.isNotEmpty() } val body = buildJsonObject { @@ -278,10 +357,24 @@ object SkinLibrary { put("model", skin.model.serializedName) if (label != null) put("name", label) } - return OutfitApi.send(HttpMethod.Post, "/skins", body) + val recorded = OutfitApi.send(HttpMethod.Post, "/skins", body) .map { it.toInfo() } - .onSuccess { recorded -> if (loadedFor == owner) replace(recorded) } - .onFailure { LOGGER.error("Failed to save skin {}", hash, it) } + .onSuccess { recorded -> + recordFailures.remove(key) + if (loadedFor == owner) replace(recorded) + } + .onFailure { + LOGGER.error("Failed to save skin {}", hash, it) + val conflict = (it as? ClientRequestException)?.response?.status == HttpStatusCode.Conflict + recordFailures[key] = RecordFailure(System.currentTimeMillis(), conflict) + } + .getOrElse { return Result.failure(it) } + if (!correctModel || recorded.model == skin.model) return Result.success(recorded) + return OutfitApi.send(HttpMethod.Patch, "/skins/${recorded.id}", buildJsonObject { put("model", skin.model.serializedName) }) + .map { it.toInfo() } + .onSuccess { updated -> if (loadedFor == owner) replace(updated) } + .onFailure { LOGGER.warn("Failed to update the arm model of skin {}", recorded.id, it) } + .recover { recorded } } private suspend fun refreshLocked(): Result = requestLock.withLock { @@ -293,18 +386,22 @@ object SkinLibrary { } val owner = ClientPlatform.localPlayerUuid() if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading - OutfitApi.send(HttpMethod.Get, "/skins") - .onSuccess { response -> - skins = response.skins.map { it.toInfo() } - limit = response.limit - loadedFor = owner - state = LibraryState.Loaded - } - .onFailure { error -> - LOGGER.error("Failed to fetch skins", error) - if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) - } - .map { } + try { + OutfitApi.send(HttpMethod.Get, "/skins") + .onSuccess { response -> + skins = response.skins.map { it.toInfo() } + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + } + .onFailure { error -> + LOGGER.error("Failed to fetch skins", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } finally { + if (state is LibraryState.Loading) state = LibraryState.Idle + } } private suspend fun patch(id: Int, body: JsonObject): Result { @@ -312,7 +409,19 @@ object SkinLibrary { return OutfitApi.send(HttpMethod.Patch, "/skins/$id", body) .map { it.toInfo() } .onSuccess(::replace) - .onFailure { LOGGER.error("Failed to update skin {}", id, it) } + .onFailure { + LOGGER.error("Failed to update skin {}", id, it) + forgetIfMissing(id, it) + } + } + + private fun forgetIfMissing(id: Int, error: Throwable) { + if ((error as? ClientRequestException)?.response?.status == HttpStatusCode.NotFound) forget(id) + } + + private fun forget(id: Int) { + skins = skins.filter { it.id != id } + OutfitLibrary.onSkinDeleted(id) } private suspend fun download(url: String): Result = runSuspendCatching { @@ -325,13 +434,23 @@ object SkinLibrary { }.onFailure { LOGGER.warn("Failed to download a skin from {}", url, it) } private fun validate(png: ByteArray) { + if (png.size > MAX_SKIN_BYTES) throw OutfitException(TOO_LARGE) if (png.size < PNG_SIGNATURE.size || !PNG_SIGNATURE.indices.all { png[it] == PNG_SIGNATURE[it] }) { throw OutfitException("Skins must be PNG images.") } - val image = runCatching { ImageIO.read(ByteArrayInputStream(png)) }.getOrNull() - ?: throw OutfitException(NOT_A_SKIN) - if (image.width != 64 || (image.height != 64 && image.height != 32)) throw OutfitException(NOT_A_SKIN) - if (png.size > MAX_SKIN_BYTES) throw OutfitException("That skin file is too large. Skins can be at most 64 KB.") + val size = runCatching { + ImageIO.createImageInputStream(ByteArrayInputStream(png)).use { input -> + val reader = ImageIO.getImageReaders(input).asSequence().firstOrNull() ?: return@use null + try { + reader.input = input + reader.getWidth(0) to reader.getHeight(0) + } finally { + reader.dispose() + } + } + }.getOrNull() ?: throw OutfitException(NOT_A_SKIN) + if (size.first != 64 || (size.second != 64 && size.second != 32)) throw OutfitException(NOT_A_SKIN) + runCatching { ImageIO.read(ByteArrayInputStream(png)) }.getOrNull() ?: throw OutfitException(NOT_A_SKIN) } private fun loadBitmap(skin: PlayerSkinInfo, key: String) { @@ -345,16 +464,48 @@ object SkinLibrary { decoded } } - bitmaps[key] = image.toComposeImageBitmap() + bitmaps[key] = previewImage(image).toComposeImageBitmap() + failed.remove(key) bitmapVersion++ }.onFailure { - failed.add(key) + failed[key] = System.currentTimeMillis() + BITMAP_RETRY_MS LOGGER.debug("Could not load the preview for skin {}", skin.id, it) } loading.remove(key) } } + private fun previewImage(source: BufferedImage): BufferedImage { + val image = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB) + image.createGraphics().apply { drawImage(source, 0, 0, null) }.dispose() + opaque(image, 0, 0, 32, 16) + if (image.height <= 32) clearIfOpaque(image, 32, 0, 64, 32) + opaque(image, 0, 16, 64, 32) + opaque(image, 16, 48, 48, 64) + return image + } + + private fun clearIfOpaque(image: BufferedImage, x0: Int, y0: Int, x1: Int, y1: Int) { + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + if (image.getRGB(x, y) ushr 24 < 128) return + } + } + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + image.setRGB(x, y, image.getRGB(x, y) and 0xFFFFFF) + } + } + } + + private fun opaque(image: BufferedImage, x0: Int, y0: Int, x1: Int, y1: Int) { + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + image.setRGB(x, y, image.getRGB(x, y) or (0xFF shl 24)) + } + } + } + private fun cacheFile(hash: String): File? = hash.lowercase().takeIf { HASH_PATTERN.matches(it) }?.let { File(cacheDir, "$it.png") } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt index 4f84a4af..be293a38 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt @@ -48,7 +48,10 @@ object EmoteWheels { fun addToFirstEmpty(index: Int, emoteId: Int): Int? { val current = slots(index) if (emoteId in current) return current.indexOf(emoteId) - val slot = current.indexOfFirst { it == null }.takeIf { it >= 0 } ?: return null + val raw = stored.wheels.getOrNull(index)?.let { wheel -> List(SLOT_COUNT) { wheel.getOrNull(it) } } ?: current + val slot = raw.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: current.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: return null assign(index, slot, emoteId) return slot } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt index 276668e0..5d715914 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.sp import net.minecraft.client.Minecraft import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger +import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -50,6 +52,7 @@ import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind import org.polyfrost.polyplus.client.emotes.EmoteWheels +import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.sqrt import kotlinx.coroutines.isActive @@ -73,6 +76,9 @@ private const val RING_STROKE_W = 1f private const val SLOT_COUNT = 8 private const val SECTOR_DEG = 360f / SLOT_COUNT +private const val SCROLL_NOTCH = 8f +private const val MAC_SCROLL_NOTCH = 2f + private val PopInEasing = CubicBezierEasing(0.0f, 0.55f, 0.45f, 1.0f) private data class WheelSlot(val id: Int, val name: String) @@ -165,7 +171,7 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { val dy = mouseYRoot - cy val dist = sqrt(dx * dx + dy * dy) if (dist in innerRadiusPx * popIn.value..wheelRadiusPx * popIn.value) { - val angle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + 90f + 360f) % 360f + val angle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + 90f + SECTOR_DEG / 2f + 360f) % 360f (angle / SECTOR_DEG).toInt().coerceIn(0, SLOT_COUNT - 1) } else { -1 @@ -173,15 +179,17 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { } } + val currentSlots by rememberUpdatedState(slots) LaunchedEffect(Unit) { while (isActive) { withFrameNanos {} if (!EmoteWheelKeybind.isHeld()) { - onRelease(slots.getOrNull(hoveredSlot)?.id) + onRelease(currentSlots.getOrNull(hoveredSlot)?.id) break } } } + val scrollAccum = remember { FloatArray(1) } Box( modifier = Modifier @@ -194,7 +202,13 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { } .onPointerEvent(PointerEventType.Scroll) { event -> val delta = event.changes.firstOrNull()?.scrollDelta?.y ?: return@onPointerEvent - if (delta != 0f) EmoteWheels.cycle(if (delta > 0f) 1 else -1) + val notch = if (DesktopHelper.isMac) MAC_SCROLL_NOTCH else SCROLL_NOTCH + scrollAccum[0] += delta + while (abs(scrollAccum[0]) >= notch) { + val step = if (scrollAccum[0] > 0f) 1 else -1 + EmoteWheels.cycle(step) + scrollAccum[0] -= step * notch + } }, contentAlignment = Alignment.Center, ) { @@ -272,7 +286,7 @@ private fun WheelCanvas( val center = Offset(cx, cy) for (i in 0 until SLOT_COUNT) { - val startDeg = -90f + i * SECTOR_DEG + val startDeg = -90f - SECTOR_DEG / 2f + i * SECTOR_DEG val hasEmote = slots.getOrNull(i) != null val isHovered = i == hoveredSlot && hasEmote diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt index 65c1ce00..57f3518a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt @@ -1,13 +1,9 @@ package org.polyfrost.polyplus.client.gui -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf @@ -19,6 +15,7 @@ import androidx.navigation.compose.composable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import net.minecraft.client.gui.screens.TitleScreen import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI import org.polyfrost.oneconfig.internal.ui.navigation.NavigationGroup import org.polyfrost.oneconfig.internal.ui.navigation.NavigationRoute @@ -81,6 +78,13 @@ object PolyPlusOneConfigIntegration { @JvmStatic fun openCosmetics() = openOneConfig(PolyPlusCosmeticsRoute) + fun openCosmeticsFromKeybind() { + when (ClientPlatform.currentScreen()) { + null, is TitleScreen, is PolyPlusMainMenuScreen -> openCosmetics() + else -> Unit + } + } + @JvmStatic fun openMods() = openOneConfig(ModsGraph) @@ -144,15 +148,21 @@ private fun PolyPlusCosmeticsScreen() { } } - fun checkout(productIds: List) { + var checkoutPending by remember { mutableStateOf(false) } + + fun checkout(productIds: List, claimed: Boolean = false) { + if (checkoutPending && !claimed) return if (productIds.isEmpty()) { + checkoutPending = false status = "Nothing purchasable in your cart yet." return } + checkoutPending = true status = "Opening checkout..." PolyPlusClient.SCOPE.launch { val result = BillingService.checkoutAndOpen(productIds) ClientPlatform.runOnMain { + checkoutPending = false status = result.fold( onSuccess = { "Checkout opened in your browser." }, onFailure = ::checkoutFailureMessage, @@ -196,7 +206,7 @@ private fun PolyPlusCosmeticsScreen() { }, onSelectVariant = { groupId, variantId -> variantPicks[groupId] = variantId - val item = allItems.firstOrNull { it.groupId == groupId } + val item = allItems.firstOrNull { it.groupId == groupId && it.variants.any { variant -> variant.id == variantId } } if (item != null && item.equipped && item.equippedVariantId != variantId) { status = "Equipping ${item.name}..." equip(item, variantId) { @@ -250,14 +260,18 @@ private fun PolyPlusCosmeticsScreen() { val label = info.cartName(variant) if (variant.id in ownedIds) { status = "You already own $label." - } else { + } else if (!checkoutPending) { + checkoutPending = true status = "Opening checkout..." PolyPlusClient.SCOPE.launch { val priceId = CosmeticStore.view(variant.id).getOrNull()?.storeProductId if (priceId.isNullOrBlank()) { - ClientPlatform.runOnMain { status = "$label isn't purchasable yet." } + ClientPlatform.runOnMain { + checkoutPending = false + status = "$label isn't purchasable yet." + } } else { - ClientPlatform.runOnMain { checkout(listOf(priceId)) } + ClientPlatform.runOnMain { checkout(listOf(priceId), claimed = true) } } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt index cc185421..0c0f7d8a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt @@ -80,15 +80,17 @@ private fun PlayerPreviewLive( } } + val sharedYaw = rotation?.yaw + val sharedPitch = rotation?.pitch SideEffect { entry.source = source entry.modelScale = modelScale entry.verticalAnchor = verticalAnchor entry.autoSpin = autoSpin - entry.initialYaw = rotation?.yaw ?: initialYaw - if (rotation != null) { + entry.initialYaw = sharedYaw ?: initialYaw + if (sharedPitch != null) { entry.dragYaw = 0f - entry.dragPitch = rotation.pitch + entry.dragPitch = sharedPitch } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index c001d305..e59c5133 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -924,10 +924,11 @@ object PlayerPreviewRenderer { } ?: MojangCapeService.localCapeTexture() } - private fun hidesCape(source: PlayerPreviewSource): Boolean = - source == PlayerPreviewSource.LocalLive && - CosmeticCatalog.localEquipped().cape == null && - MojangCapeService.localCapeHidden() + private fun hidesCape(source: PlayerPreviewSource): Boolean = when (source) { + is PlayerPreviewSource.Override -> source.capeCosmeticId == null && source.mojangCape == null + PlayerPreviewSource.LocalLive -> + CosmeticCatalog.localEquipped().cape == null && MojangCapeService.localCapeHidden() + } //? if >= 1.21.10 { private fun withCape(skin: PlayerSkin, cape: Identifier): PlayerSkin = diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt index f7e46daa..f9968e0b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt @@ -17,12 +17,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,6 +34,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush @@ -38,6 +43,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -208,6 +214,7 @@ internal fun AuraCustomPopover( ) { val state = remember { AuraPickerState(selectedColor ?: 0xFFFFFFFF.toInt()) } var hexText by remember { mutableStateOf(argbHex(state.currentArgb())) } + var hexPending by remember { mutableStateOf(false) } LaunchedEffect(selectedColor) { if (selectedColor != null && selectedColor != state.lastEmitted) { state.applyFrom(selectedColor) @@ -225,9 +232,17 @@ internal fun AuraCustomPopover( fun emitCommit() { val argb = state.currentArgb() state.lastEmitted = argb + hexPending = false onCommit(argb) } + fun commitHex() { + if (hexPending) emitCommit() + } + + val latestCommitHex by rememberUpdatedState<() -> Unit> { commitHex() } + DisposableEffect(Unit) { onDispose { latestCommitHex() } } + val hue = state.hue val saturation = state.saturation val brightness = state.brightness @@ -387,14 +402,17 @@ internal fun AuraCustomPopover( BasicTextField( value = hexText, onValueChange = { input -> - val filtered = input.filter { it.isLetterOrDigit() }.take(6) + val filtered = input.filter { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }.take(6) hexText = filtered hexToArgb(filtered, state.alpha)?.let { argb -> state.applyFrom(argb) state.lastEmitted = argb - onCommit(argb) + hexPending = true + onPreview(argb) } }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { commitHex() }), singleLine = true, textStyle = TextStyle( color = LocalTheme.current.textColor, @@ -407,6 +425,7 @@ internal fun AuraCustomPopover( .background(LocalTheme.current.chipBackground) .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) .padding(horizontal = 8.dp, vertical = 6.dp) + .onFocusChanged { if (!it.isFocused) commitHex() } .trackTextInputFocus(), decorationBox = { inner -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt index 74e7fad4..ba6c6692 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt @@ -1,9 +1,10 @@ package org.polyfrost.polyplus.client.gui.wardrobe import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.runtime.remember import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView @@ -17,12 +18,28 @@ import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel import org.polyfrost.polyplus.client.network.http.responses.CosmeticType import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.util.concurrent.atomic.AtomicBoolean + +private val lookChangePending = AtomicBoolean(false) +private val equipLock = Mutex() + +private const val LOOK_CHANGE_BUSY = "Another change is still running. Try again in a moment." + +private fun beginLookChange(): Boolean = + !OutfitLibrary.busy && !SkinLibrary.busy && !MojangCapeService.busy && lookChangePending.compareAndSet(false, true) + +private fun beginLookChange(onStatus: (String) -> Unit): Boolean = + beginLookChange().also { if (!it) onStatus(LOOK_CHANGE_BUSY) } internal fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { - if (MojangCapeService.busy) return + if (!beginLookChange(onStatus)) return onStatus(if (cape.active) "Removing ${cape.name}..." else "Equipping ${cape.name}...") PolyPlusClient.SCOPE.launch { - val result = if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) + val result = try { + if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) + } finally { + lookChangePending.set(false) + } ClientPlatform.runOnMain { onStatus( result.fold( @@ -48,22 +65,29 @@ private fun launchLibraryCall( } } +private suspend fun releasing(flag: AtomicBoolean, call: suspend () -> Result): Result = + try { + call() + } finally { + flag.set(false) + } + internal fun wearOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { - if (OutfitLibrary.busy) return + if (!beginLookChange(onStatus)) return launchLibraryCall( onStatus, "Switching to ${outfit.name}...", - { OutfitLibrary.apply(outfit.id) }, + { releasing(lookChangePending) { OutfitLibrary.apply(outfit.id) } }, OutfitLibrary::failureMessage, ) { "Wearing ${it.name}." } } internal fun createOutfitFromCurrent(onStatus: (String) -> Unit) { - if (OutfitLibrary.busy) return + if (!beginLookChange(onStatus)) return launchLibraryCall( onStatus, "Saving your current look...", - { OutfitLibrary.createFromCurrent() }, + { releasing(lookChangePending) { OutfitLibrary.createFromCurrent() } }, OutfitLibrary::failureMessage, ) { "Saved ${it.name}." } } @@ -96,12 +120,16 @@ internal fun deleteOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { ) { "Deleted ${outfit.name}." } } +internal fun beginSkinChange(): Boolean = beginLookChange() + +internal fun endSkinChange() = lookChangePending.set(false) + internal fun wearSkin(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { - if (SkinLibrary.busy) return + if (!beginLookChange(onStatus)) return launchLibraryCall( onStatus, "Changing your skin to ${skin.name}...", - { SkinLibrary.wear(skin.id) }, + { releasing(lookChangePending) { SkinLibrary.wear(skin.id) } }, SkinLibrary::failureMessage, ) { "Wearing ${skin.name}." } } @@ -127,10 +155,11 @@ internal fun toggleSkinFavorite(skin: PlayerSkinInfo, onStatus: (String) -> Unit internal fun switchSkinModel(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { val model = if (skin.model == SkinModel.Slim) SkinModel.Classic else SkinModel.Slim + if (!beginLookChange(onStatus)) return launchLibraryCall( onStatus, null, - { SkinLibrary.setModel(skin.id, model) }, + { releasing(lookChangePending) { SkinLibrary.setModel(skin.id, model) } }, SkinLibrary::failureMessage, ) { "${it.name} now uses ${if (it.model == SkinModel.Slim) "slim" else "classic"} arms." } } @@ -160,16 +189,17 @@ internal fun rememberCosmeticItems(refreshKey: Int): List = CosmeticVariantUi(variant.id, variant.variantName) } } - val equippedVariantId = group.variants.firstOrNull { it.id in equippedIds }?.id + val equippedVariant = group.variants.firstOrNull { it.id in equippedIds } CosmeticUiItem( groupId = group.groupId, type = group.type, name = group.name, collection = "${group.type.displayName} Collection", owned = group.variants.any { it.id in ownedIds }, - equipped = equippedVariantId != null, + equipped = equippedVariant != null, variants = byLabel.values.toList(), - equippedVariantId = equippedVariantId, + equippedVariantId = equippedVariant?.let { byLabel[it.variantName]?.id ?: it.id }, + equippedCosmeticId = equippedVariant?.id, ) } @@ -198,10 +228,12 @@ internal fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> } PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.equipEmote(variantId) - } else { - CosmeticService.equip(variantId) + val result = equipLock.withLock { + if (item.type == CosmeticType.Emote) { + CosmeticService.equipEmote(variantId) + } else { + CosmeticService.equip(variantId) + } } ClientPlatform.runOnMain { onComplete( @@ -216,15 +248,17 @@ internal fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> internal fun unequip(item: CosmeticUiItem, onComplete: (String) -> Unit) { PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.clearEmote() - } else { - val slot = CosmeticCatalog.localEquipped().equipped.entries - .firstOrNull { it.value == item.equippedVariantId }?.key - if (slot == null) { - Result.failure(IllegalStateException("${item.name} is not equipped")) + val result = equipLock.withLock { + if (item.type == CosmeticType.Emote) { + CosmeticService.clearEmote() } else { - CosmeticService.clearSlot(slot) + val slot = CosmeticCatalog.localEquipped().equipped.entries + .firstOrNull { it.value == item.equippedCosmeticId }?.key + if (slot == null) { + Result.success(Unit) + } else { + CosmeticService.clearSlot(slot) + } } } ClientPlatform.runOnMain { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt index 8e98b646..e4b4933b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -197,7 +197,7 @@ internal fun WardrobeGrid( menu = listOf( CardMenuAction("Preview") { onShop(info) }, CardMenuAction("Add to cart", enabled = !inCart && !owned && variant.id !in ownedIds) { onAddToCart(info, variant) }, - CardMenuAction("Buy now", enabled = !owned) { onBuyNow(info, variant) }, + CardMenuAction("Buy now", enabled = !owned && variant.id !in ownedIds) { onBuyNow(info, variant) }, ), ) { modifier -> val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt index af86d346..c1cefbb6 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt @@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -59,8 +57,11 @@ internal fun HistoryScreen(ownedIds: Set, reloadKey: Int) { loading && transactions.isEmpty() -> CenteredNote("Loading transactions...") loadError != null && transactions.isEmpty() -> CenteredNote(loadError!!) transactions.isEmpty() -> CenteredNote("No transactions yet.") - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { - items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } + else -> Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + loadError?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } + LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { + items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } + } } } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt index 3295e54d..a1e0926e 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt @@ -11,13 +11,13 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.floor import kotlin.math.min import kotlin.math.roundToInt @@ -59,6 +60,7 @@ import org.polyfrost.polyplus.client.cosmetics.MojangCapeState import org.polyfrost.polyplus.client.cosmetics.PetDefinition import org.polyfrost.polyplus.client.cosmetics.outfits.LibraryState import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitException import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary @@ -71,7 +73,7 @@ import org.polyfrost.polyplus.client.network.http.responses.BodySlot import org.polyfrost.polyplus.client.utils.ClientPlatform -internal const val MAX_LIBRARY_NAME_LENGTH = 32 +internal const val MAX_LIBRARY_NAME_LENGTH = 64 private const val MAX_USERNAME_LENGTH = 16 @@ -266,6 +268,8 @@ internal fun AddSkinPanel(onClose: () -> Unit, onStatus: (String) -> Unit, modif var picking by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } + val shown = remember { AtomicBoolean(true) } + DisposableEffect(Unit) { onDispose { shown.set(false) } } fun chooseFile() { if (picking) return @@ -299,23 +303,27 @@ internal fun AddSkinPanel(onClose: () -> Unit, onStatus: (String) -> Unit, modif message = problem return } + if (!beginSkinChange()) { + message = "Another skin change is still running." + return + } working = true message = "Adding skin..." PolyPlusClient.SCOPE.launch { - val result = when (chosen) { - SkinSource.Username -> SkinLibrary.addFromUsername(player) - SkinSource.Url -> SkinLibrary.addFromUrl(link, label, arms) - SkinSource.File -> { - val bytes = path?.let { readSkinFile(it) } - if (bytes == null) { - ClientPlatform.runOnMain { - working = false - message = "Couldn't read that file." - } - return@launch + val result = try { + when (chosen) { + SkinSource.Username -> SkinLibrary.addFromUsername(player) + SkinSource.Url -> SkinLibrary.addFromUrl(link, label, arms) + SkinSource.File -> { + val bytes = (path?.let { readSkinFile(it) } ?: Result.failure(OutfitException(UNREADABLE_SKIN_FILE))) + bytes.fold( + onSuccess = { SkinLibrary.addFromBytes(it, label, arms) }, + onFailure = { Result.failure(it) }, + ) } - SkinLibrary.addFromBytes(bytes, label, arms) } + } finally { + endSkinChange() } ClientPlatform.runOnMain { working = false @@ -323,7 +331,7 @@ internal fun AddSkinPanel(onClose: () -> Unit, onStatus: (String) -> Unit, modif onSuccess = { skin -> message = null onStatus("Added ${skin.name} to your skins.") - onClose() + if (shown.get()) onClose() }, onFailure = { message = SkinLibrary.failureMessage(it) }, ) @@ -467,8 +475,15 @@ private fun FieldGroup(label: String, content: @Composable () -> Unit) { } } -private suspend fun readSkinFile(path: Path): ByteArray? = - withContext(Dispatchers.IO) { runCatching { Files.readAllBytes(path) }.getOrNull() } +private suspend fun readSkinFile(path: Path): Result = + withContext(Dispatchers.IO) { + val size = runCatching { Files.size(path) } + .getOrElse { return@withContext Result.failure(OutfitException(UNREADABLE_SKIN_FILE)) } + if (size > SkinLibrary.MAX_FILE_BYTES) return@withContext Result.failure(OutfitException(SkinLibrary.TOO_LARGE)) + runCatching { Files.readAllBytes(path) }.recoverCatching { throw OutfitException(UNREADABLE_SKIN_FILE) } + } + +private const val UNREADABLE_SKIN_FILE = "Couldn't read that file." private fun pickSkinFile(): Path? = TinyFdApi.getInstance().openFileSelector("Choose a skin", null, arrayOf("*.png"), "PNG images") diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt index 4d20f19e..d2486ec2 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt @@ -41,6 +41,7 @@ internal fun CosmeticsLoadingScreen( onRetry: () -> Unit, ) { val failed = state.failure != null + val outdatedRuntime = state.failure == CosmeticLoadProgress.OUTDATED_RUNTIME Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -54,10 +55,10 @@ internal fun CosmeticsLoadingScreen( textAlign = TextAlign.Center, ) SocialText( - if (failed) { - "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." - } else { - state.label + when { + outdatedRuntime -> "PolyPlus needs a newer Fabric Language Kotlin. Update it and restart the game." + failed -> "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." + else -> state.label }, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, @@ -65,8 +66,10 @@ internal fun CosmeticsLoadingScreen( textAlign = TextAlign.Center, ) if (failed) { - Spacer(Modifier.height(2.dp)) - SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) + if (!outdatedRuntime) { + Spacer(Modifier.height(2.dp)) + SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) + } } else { ProgressBar(state.fraction, Modifier.width(360.dp)) } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt index c00493c0..8a464a02 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -1,7 +1,5 @@ package org.polyfrost.polyplus.client.gui.wardrobe -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.key import org.polyfrost.polyplus.client.cosmetics.BillingService import org.polyfrost.polyplus.client.cosmetics.MojangCape import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo @@ -40,7 +38,7 @@ internal fun CosmeticStoreInfo.toCartEntry(variant: CosmeticVariantUi, storeProd internal fun cosmeticCartKey(variantId: Int): String = "cosmetic-$variantId" internal fun CosmeticStoreInfo.cartName(variant: CosmeticVariantUi): String = - if (variantList.size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name + if (uiVariants().size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name internal data class CosmeticVariantUi(val id: Int, val name: String) @@ -84,6 +82,7 @@ internal data class CosmeticUiItem( val equipped: Boolean, val variants: List, val equippedVariantId: Int?, + val equippedCosmeticId: Int? = equippedVariantId, ) { val id: Int get() = groupId val hasVariants: Boolean get() = variants.size > 1 @@ -92,7 +91,7 @@ internal data class CosmeticUiItem( internal const val MOJANG_CAPE_COLLECTION = "Minecraft cape" internal fun selectedVariantId(item: CosmeticUiItem, picks: Map): Int? = - picks[item.groupId] ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id + picks[item.groupId]?.takeIf { id -> item.variants.any { it.id == id } } ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id internal enum class WardrobeSection(val label: String, val icon: String) { Featured("Featured", "star-filled"), diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt index d0b33438..bd3f6de8 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt @@ -16,10 +16,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -35,6 +39,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -195,7 +200,7 @@ internal fun VariantChips( ownedVariantIds: Set = emptySet(), ) { FlowRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().heightIn(max = MAX_CHIP_HEIGHT).verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -259,8 +264,14 @@ private fun VariantChip(variant: CosmeticVariantUi, selected: Boolean, dimmed: B color = if (selected) theme.accentTextColor else theme.textColor, fontSize = 12.sp, maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = MAX_CHIP_LABEL_WIDTH), ) } } +private val MAX_CHIP_HEIGHT = 96.dp + +private val MAX_CHIP_LABEL_WIDTH = 120.dp + private val PILL_RADIUS = 18.dp diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt index 4ad9a6aa..a3bab7af 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -9,16 +9,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -65,7 +61,14 @@ internal fun WardrobeShell( ) { val store = remember { WardrobeStoreData() } var storeReloadKey by remember { mutableIntStateOf(0) } - LaunchedEffect(storeReloadKey) { if (polyPlusAvailable) store.load() else store.loading = false } + LaunchedEffect(storeReloadKey) { + if (polyPlusAvailable) { + store.load() + } else { + store.loading = false + store.featuredLoading = false + } + } LaunchedEffect(Unit) { MojangCapeService.refreshIfNeeded() } val sections = if (polyPlusAvailable) WardrobeSection.entries else listOf(WardrobeSection.Cosmetics) @@ -96,7 +99,7 @@ internal fun WardrobeShell( LaunchedEffect(section) { if (!polyPlusAvailable) return@LaunchedEffect when (section) { - WardrobeSection.Outfits -> OutfitLibrary.refreshIfNeeded() + WardrobeSection.Outfits -> OutfitLibrary.refreshIfNeeded(reload = true) WardrobeSection.Skins -> { SkinLibrary.refreshIfNeeded() OutfitLibrary.refreshIfNeeded() @@ -104,9 +107,9 @@ internal fun WardrobeShell( else -> Unit } } - val outfitKey = listOf(OutfitLibrary.outfits, OutfitLibrary.activeOutfitId, OutfitLibrary.state) - val skinKey = listOf(SkinLibrary.skins, SkinLibrary.state, SkinLibrary.wornSkinId()) - val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, mojangState, query, sort, emoteKeybindWarning, outfitKey, skinKey) { + val outfitKey = listOf(OutfitLibrary.outfits, OutfitLibrary.activeOutfitId, OutfitLibrary.state, OutfitLibrary.limit) + val skinKey = listOf(SkinLibrary.skins, SkinLibrary.state, SkinLibrary.wornSkinId(), SkinLibrary.limit) + val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, store.featuredLoading, store.failedTypes, store.featuredFailed, mojangState, query, sort, emoteKeybindWarning, outfitKey, skinKey) { buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable, emoteKeybindWarning) } val coverByVariant = remember(store.byType, store.popular, store.newest) { @@ -235,6 +238,7 @@ internal fun WardrobeShell( onSelectVariant = onSelectVariant, onShop = { info -> val ownedItem = items.firstOrNull { item -> item.owned && info.variantList.any { v -> item.variants.any { it.id == v.id } } } + ?.takeIf { info.fullyOwned(ownedIds) } selection = if (ownedItem != null) WardrobeSelection.Owned(ownedItem.groupId, ownedItem.type) else WardrobeSelection.Shop(info.id) showCart = false }, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt index e96d3777..3a56f39c 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -1,13 +1,11 @@ package org.polyfrost.polyplus.client.gui.wardrobe -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import org.polyfrost.polyplus.client.cosmetics.BundleCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticStore import org.polyfrost.polyplus.client.cosmetics.MojangCapeState @@ -24,41 +22,79 @@ internal class WardrobeStoreData { var newest by mutableStateOf>(emptyList()) var bundles by mutableStateOf>(emptyList()) var loading by mutableStateOf(true) - var failed by mutableStateOf(false) + var featuredLoading by mutableStateOf(true) + var failedTypes by mutableStateOf>(emptySet()) + var featuredFailed by mutableStateOf(false) val all: List get() = byType.values.flatten() + popular + newest suspend fun load() { loading = true + featuredLoading = true val types = CosmeticType.entries.filter { it != CosmeticType.Unknown } coroutineScope { - val perType = types.map { type -> - async { - type to CosmeticStore.search( - perPage = CosmeticStore.MAX_PAGE_SIZE, - sort = CosmeticStore.Sort.Popularity, - types = listOf(type.serializedName), - reportFailures = false, - ).getOrNull() + launch { + val popularFetched = async { + CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Popularity).getOrNull() + ?.also { response -> popular = response.results.filter { it.type != CosmeticType.Unknown } } + } + val newestFetched = async { + CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Newest).getOrNull() + ?.also { response -> newest = response.results.filter { it.type != CosmeticType.Unknown } } + } + val bundlesFetched = async { + BundleCatalog.search(perPage = FEATURED_ROW_SIZE).getOrNull() + ?.also { response -> bundles = response.bundles.filter { it.purchasable } } + } + featuredFailed = popularFetched.await() == null && newestFetched.await() == null && bundlesFetched.await() == null + featuredLoading = false + } + for (type in types) { + launch { + val fetched = searchAll(type) + val previous = byType[type] + val results = when { + fetched == null -> previous + fetched.partial && previous != null -> previous + else -> fetched.items + } + if (results != null) byType = byType + (type to results) + failedTypes = if (results == null) failedTypes + type else failedTypes - type } } - val popularResult = async { CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Popularity) } - val newestResult = async { CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Newest) } - val bundleResult = async { BundleCatalog.search(perPage = FEATURED_ROW_SIZE) } - - val fetched = perType.awaitAll() - byType = fetched.mapNotNull { (type, response) -> response?.let { type to it.results } }.toMap() - popular = popularResult.await().getOrNull()?.results.orEmpty() - newest = newestResult.await().getOrNull()?.results.orEmpty() - bundles = bundleResult.await().getOrNull()?.bundles.orEmpty().filter { it.purchasable } - failed = fetched.all { it.second == null } } + featuredLoading = false loading = false } + + private class StoreResults(val items: List, val partial: Boolean) + + private suspend fun searchAll(type: CosmeticType): StoreResults? { + val results = LinkedHashMap() + var page = 1 + do { + val response = CosmeticStore.search( + page = page, + perPage = CosmeticStore.MAX_PAGE_SIZE, + sort = CosmeticStore.Sort.Popularity, + types = listOf(type.serializedName), + reportFailures = false, + ).getOrNull() ?: return if (page == 1) null else StoreResults(results.values.toList(), partial = true) + response.results.forEach { results.putIfAbsent(it.id, it) } + page++ + } while (page <= response.pagination.totalPages && page <= MAX_STORE_PAGES) + return StoreResults(results.values.toList(), partial = false) + } } private const val FEATURED_ROW_SIZE = 6 +private const val MAX_STORE_PAGES = 10 + +private const val STORE_LOADING = "Loading the store..." + +private const val STORE_FAILED = "Couldn't reach the PolyPlus store." + internal fun buildWardrobeEntries( section: WardrobeSection, items: List, @@ -74,7 +110,7 @@ internal fun buildWardrobeEntries( fun matches(name: String) = needle.isEmpty() || name.contains(needle, ignoreCase = true) fun owned(type: CosmeticType) = items.filter { it.owned && it.type == type && matches(it.name) } fun shop(type: CosmeticType) = store.byType[type].orEmpty() - .filter { !it.anyOwned(ownedIds) && matches(it.name) } + .filter { !it.fullyOwned(ownedIds) && matches(it.name) } .sortedFor(sort) val entries = mutableListOf() @@ -99,8 +135,8 @@ internal fun buildWardrobeEntries( entries += WardrobeEntry.Note( "featured", when { - store.loading -> "Loading the store..." - store.failed -> "Couldn't reach the PolyPlus store." + store.featuredLoading -> STORE_LOADING + store.featuredFailed -> STORE_FAILED else -> "Nothing featured right now." }, ) @@ -175,8 +211,15 @@ internal fun buildWardrobeEntries( entries += WardrobeEntry.Divider(category, anchor = true) infos.mapTo(entries) { WardrobeEntry.Shop(it, "emote") } } - if (entries.isEmpty()) { - entries += WardrobeEntry.Note("emotes", if (store.loading) "Loading emotes..." else "No emotes found.") + if (entries.all { it is WardrobeEntry.Banner }) { + entries += WardrobeEntry.Note( + "emotes", + when { + CosmeticType.Emote in store.failedTypes -> STORE_FAILED + store.loading -> "Loading emotes..." + else -> "No emotes found." + }, + ) } } @@ -216,7 +259,14 @@ internal fun buildWardrobeEntries( "PolyPlus capes need the Terms of Service and Privacy Policy. Accept them under Privacy in the PolyPlus settings.", ) } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { - entries += WardrobeEntry.Note("polyplus", "No PolyPlus capes found.") + entries += WardrobeEntry.Note( + "polyplus", + when { + CosmeticType.Cape in store.failedTypes -> STORE_FAILED + store.loading -> STORE_LOADING + else -> "No PolyPlus capes found." + }, + ) } } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { continue diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt index 3c97e532..ba28dc62 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt @@ -15,12 +15,10 @@ 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.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -82,7 +80,7 @@ private fun CartPanel( } } - val subtotal = items.sumOf { (it.basePrice ?: 0f).toDouble() }.toFloat() + val subtotal = items.sumOf { (it.basePrice ?: it.finalPrice ?: 0f).toDouble() }.toFloat() val total = items.sumOf { (it.finalPrice ?: it.basePrice ?: 0f).toDouble() }.toFloat() val discount = (subtotal - total).coerceAtLeast(0f) RowTotals("Subtotal", money(subtotal), LocalTheme.current.textColor) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt index 94c6adcb..3f116be4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt @@ -94,12 +94,6 @@ private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (t else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) } -@Composable -internal fun rememberCosmeticPreviewSource(item: CosmeticUiItem): Pair { - val cosmeticId = item.equippedVariantId ?: item.variants.firstOrNull()?.id ?: return null to 0 - return rememberCosmeticPreviewSource(cosmeticId, item.type) -} - @Composable internal fun rememberCosmeticPreviewSource(cosmeticId: Int, type: CosmeticType): Pair { val isCape = type == CosmeticType.Cape diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt index fb6c4dac..08d3e936 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt @@ -76,9 +76,11 @@ internal fun EmoteWheelEditor( }) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SmallButton("New wheel", iconPath = "plus", primary = false, onClick = { - if (EmoteWheels.addWheel()) onSelectSlot(null) - }) + if (wheelCount < EmoteWheels.MAX_WHEELS) { + SmallButton("New wheel", iconPath = "plus", primary = false, onClick = { + if (EmoteWheels.addWheel()) onSelectSlot(null) + }) + } if (wheelCount > 1) { SmallButton("Remove", iconPath = "trash", primary = false, onClick = { EmoteWheels.removeWheel(wheelIndex)