diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/AccountSwitcherOverlay.kt b/src/main/kotlin/org/polyfrost/polyplus/client/AccountSwitcherOverlay.kt new file mode 100644 index 00000000..e368b0b8 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/AccountSwitcherOverlay.kt @@ -0,0 +1,54 @@ +package org.polyfrost.polyplus.client + +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.TitleScreen +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.gui.AccountSwitcherScreen +import org.polyfrost.polyplus.client.gui.PolyPlusMainMenuScreen + +//? if = 1.8.9 { +/*import org.polyfrost.polyplus.client.social.execute +import org.polyfrost.polyplus.client.social.setScreen +*///?} + +object AccountSwitcherOverlay { + private val logger = LogManager.getLogger("polyplus/account-switcher-overlay") + + fun open(from: Screen? = currentScreen()) { + val mc = Minecraft.getInstance() + //? if >= 26.2 { + mc.gui.setScreen(AccountSwitcherScreen(from)) + //?} else { + /*mc.setScreen(AccountSwitcherScreen(from)) + *///?} + + } + + fun close() { + val mc = Minecraft.getInstance() + mc.execute { + //? if >= 26.2 { + (mc.gui.screen() as? AccountSwitcherScreen)?.let { mc.gui.setScreen(it.previousScreen) } + //?} else { + /*(mc.screen as? AccountSwitcherScreen)?.let { mc.setScreen(it.previousScreen) } + *///?} + } + } + + //? if = 1.8.9 { + /*fun restoreAfterEscape(previousScreen: Screen?) { + val mc = Minecraft.getInstance() + if (mc.screen == null || mc.screen is TitleScreen) mc.setScreen(previousScreen) + } + *///?} + + private fun currentScreen(): Screen? { + val mc = Minecraft.getInstance() + //? if >= 26.2 { + return mc.gui.screen() + //?} else { + /*return mc.screen + *///?} + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountActions.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountActions.kt new file mode 100644 index 00000000..c1f33c08 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountActions.kt @@ -0,0 +1,141 @@ +package org.polyfrost.polyplus.client.gui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.polyfrost.polyplus.client.launcher.MicrosoftAuth +import org.polyfrost.polyplus.client.launcher.MicrosoftAuthException +import org.polyfrost.polyplus.client.launcher.OneLauncherAccounts +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.util.UUID + +internal class AccountActions(private val scope: CoroutineScope) { + var accounts by mutableStateOf?>(null) + private set + var busy by mutableStateOf(null) + private set + var error by mutableStateOf(null) + private set + var errorSteps by mutableStateOf?>(null) + private set + var loginSession by mutableStateOf(null) + private set + var refreshingId by mutableStateOf(null) + private set + private var loginJob: Job? = null + + val idle: Boolean get() = busy == null + + suspend fun reload() { + accounts = withContext(Dispatchers.IO) { OneLauncherAccounts.list() } + } + + fun clearError() { + error = null + errorSteps = null + } + + private fun fail(throwable: Throwable, fallback: String) { + error = throwable.message ?: fallback + errorSteps = (throwable as? MicrosoftAuthException)?.stepsToFix?.takeIf { it.isNotEmpty() } + } + + private fun runBusy(status: String, block: suspend () -> Unit) { + if (busy != null) return + scope.launch { + busy = status + clearError() + try { + block() + reload() + } finally { + busy = null + } + } + } + + fun switchTo(account: OneLauncherAccounts.Account) { + if (account.active) return + runBusy("Switching…") { + val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.switchTo(account.id) } + if (!ok) error = "Couldn't switch to that account" + } + } + + fun refresh(account: OneLauncherAccounts.Account) = runBusy("Refreshing session…") { + refreshingId = account.id + try { + runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.refresh(account.id) } } + .onFailure { fail(it, "Couldn't refresh that account") } + } finally { + refreshingId = null + } + } + + fun addOffline(username: String, onSuccess: () -> Unit = {}) = runBusy("Adding account…") { + runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addOffline(username) } } + .onSuccess { onSuccess() } + .onFailure { error = it.message ?: "Couldn't add that account" } + } + + fun remove(account: OneLauncherAccounts.Account) = runBusy("Removing account…") { + val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.remove(account.id) } + if (!ok) error = "Couldn't remove that account" + } + + fun addMicrosoft() { + if (busy != null) return + loginJob = scope.launch { + clearError() + busy = "Starting Microsoft sign-in…" + var started: MicrosoftAuth.MicrosoftLoginSession? = null + try { + val session = runSuspendCatching { + withContext(Dispatchers.IO) { OneLauncherAccounts.beginLogin().also { started = it } } + } + .onFailure { fail(it, "Couldn't start sign-in") } + .getOrNull() + if (session == null) { + busy = null + loginJob = null + return@launch + } + loginSession = session + ClientPlatform.openUri(session.browserAuthUrl) + busy = "Waiting for you to finish signing in…" + runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.finishLogin(session) } } + .onFailure { fail(it, "Microsoft sign-in failed") } + } finally { + started?.let { runCatching { OneLauncherAccounts.cancelLogin(it) } } + } + loginSession = null + loginJob = null + reload() + busy = null + } + } + + fun cancelLogin() { + loginJob?.cancel() + loginSession?.let { runCatching { OneLauncherAccounts.cancelLogin(it) } } + loginJob = null + loginSession = null + busy = null + clearError() + } +} + +@Composable +internal fun rememberAccountActions(): AccountActions { + val scope = rememberCoroutineScope() + return remember(scope) { AccountActions(scope) } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcher.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcher.kt new file mode 100644 index 00000000..fd0a9fde --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcher.kt @@ -0,0 +1,314 @@ +package org.polyfrost.polyplus.client.gui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +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.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +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.unit.dp +import androidx.compose.ui.unit.sp +import net.minecraft.client.gui.screens.Screen +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.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.launcher.OneLauncherAccounts + +@Composable +fun AccountSwitcherContent(screen: Screen, onClose: () -> Unit) { + val actions = rememberAccountActions() + var offlineEntry by remember { mutableStateOf(false) } + // list() is in add order, newest last + val accounts = actions.accounts + val idle = actions.idle + + LaunchedEffect(Unit) { actions.reload() } + + CompositionLocalProvider(LocalDensity provides scaledDensity(SOCIAL_UI_SCALE)) { + Box( + modifier = Modifier + .fillMaxSize() + .background(SocialWindowScrim) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }) { if (idle) onClose() }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.82f) + .widthIn(max = 1180.dp) + .fillMaxHeight(0.86f) + .heightIn(max = 760.dp) + .clip(SocialWindowShape) + .socialGlow() + .background(SocialWindowBackground) + .border(SocialBorderWidth, SocialBorderColor, SocialWindowShape) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}, + ) { + Row( + modifier = Modifier.fillMaxWidth().height(60.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + SocialText(text = "Accounts", fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + SocialIconButton(SOCIAL_ASSETS + "x-close.svg", tooltip = "Close", onClick = onClose) + } + Box(Modifier.fillMaxWidth().height(1.dp).background(SocialBorderColor)) + Row(Modifier.fillMaxWidth().weight(1f)) { + // the live preview reads the session user, so rebuild it when the active account changes + key(accounts?.firstOrNull { it.active }?.id) { + PlayerPreview( + Modifier.width(320.dp).fillMaxHeight(), + source = PlayerPreviewSource.LocalLive, + initialYaw = 180f + 22.9f, + live = true, + modelScale = 0.35F + ) + } + Box(Modifier.width(1.dp).fillMaxHeight().background(SocialBorderColor)) + Column( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + when { + accounts == null -> SocialText( + "Loading…", + fontSize = 14.sp, + color = SocialTextSecondary + ) + + accounts.isEmpty() -> SocialText( + "No accounts found in the launcher", + fontSize = 14.sp, + color = SocialTextSecondary, + ) + + else -> accounts.forEach { account -> + key(account.id) { + AccountButton( + account = account, + enabled = idle, + onClick = { actions.switchTo(account) }, + onRemove = { actions.remove(account) }, + onRefresh = { actions.refresh(account) }, + refreshing = actions.refreshingId == account.id, + ) + } + } + } + } + AccountStatusText(actions.busy, actions.error, actions.errorSteps) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + AddAccountButton( + icon = ASSETS + "globe-01.svg", + text = "Add Microsoft account", + enabled = idle, + onClick = actions::addMicrosoft, + ) + Spacer(Modifier.padding(12.dp)) + AddAccountButton( + icon = ASSETS + "plus.svg", + text = "Add offline account", + enabled = idle, + onClick = { + actions.clearError() + offlineEntry = true + }, + ) + } + + } + } + } + } + + if (offlineEntry) { + OfflineAccountPopup( + busy = actions.busy, + error = actions.error, + onSubmit = { username -> actions.addOffline(username) { offlineEntry = false } }, + onCancel = { + actions.clearError() + offlineEntry = false + }, + ) + } + + val session = actions.loginSession + if (session != null) { + MicrosoftLoginPopup( + code = session.userCode, + verificationUri = session.verificationUri, + browserAuthUrl = session.browserAuthUrl, + status = actions.busy, + onCancel = actions::cancelLogin, + ) + } + } +} + +@Composable +fun AccountButton( + account: OneLauncherAccounts.Account, + enabled: Boolean, + onClick: () -> Unit, + onRemove: () -> Unit, + onRefresh: () -> Unit, + refreshing: Boolean = false, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + var confirmRemove by remember { mutableStateOf(false) } + val clickable = enabled && !account.active && !confirmRemove + val borderColor = when { + account.expired -> SocialDangerColor + account.active -> Accent + else -> LocalTheme.current.borderColor + } + + Row( + modifier = Modifier + .clip(ppShape(8.dp)) + .background(if (hovered && clickable) LocalTheme.current.componentBackground.copy(alpha = 0.4f) else Color.Transparent) + .hoverable(interaction) + .then(if (clickable) Modifier.clickableWithSound(onClick) else Modifier) + .alpha(if (refreshing) 0.7f else if (enabled || account.active) 1f else 0.5f) + .fillMaxWidth() + .border(1.dp, borderColor, ppShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SocialAvatar(account.id.toString(), 36.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.SpaceEvenly) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SocialText( + account.username, + fontSize = 20.sp, + color = LocalTheme.current.textColor, + textAlign = TextAlign.Right + ) + if (account.active) AccountBadge(ASSETS + "check-circle.svg", "Default", Accent) + if (account.expired) AccountBadge(ASSETS + "alert-triangle.svg", "Expired", SocialDangerColor) + } + SocialText( + if (confirmRemove) "Remove this account?" + else (if (account.microsoft) "Microsoft" else "Offline") + " · " + account.id, + fontSize = 13.sp, + color = if (confirmRemove) SocialDangerColor else LocalTheme.current.textColorSecondary, + textAlign = TextAlign.Right + ) + } + if (confirmRemove) { + AccountActionIcon(ASSETS + "check-circle.svg", SocialDangerColor, enabled) { + confirmRemove = false + onRemove() + } + AccountActionIcon(ASSETS + "x-close.svg", SocialTextSecondary, enabled) { confirmRemove = false } + } else { + if (account.microsoft) { + RefreshAccountButton( + color = if (account.expired) SocialDangerColor else SocialTextSecondary, + enabled = enabled, + spinning = refreshing, + onClick = onRefresh, + ) + } + AccountActionIcon(ASSETS + "trash-01.svg", SocialTextSecondary, enabled) { confirmRemove = true } + } + } +} + +@Composable +private fun AccountBadge(icon: String, text: String, color: Color) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(LocalTheme.current.componentBackground) + .border(1.dp, color, RoundedCornerShape(50)) + .padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(icon, color, Modifier.size(12.dp)) + SocialText(text, fontSize = 10.sp, fontWeight = FontWeight.Medium, color = color) + } +} + +@Composable +private fun RefreshAccountButton(color: Color, enabled: Boolean, spinning: Boolean, onClick: () -> Unit) { + val rotation = if (spinning) { + val transition = rememberInfiniteTransition() + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(REFRESH_SPIN_MS, easing = LinearEasing)), + ) + angle + } else 0f + Box( + modifier = Modifier + .size(30.dp) + .clip(ppShape(6.dp)) + .alpha(if (enabled || spinning) 1f else 0.4f) + .then(if (enabled) Modifier.clickableWithSound(onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + Icon(ASSETS + "refresh-cw-01.svg", color, Modifier.size(18.dp).rotate(rotation)) + } +} + +private const val REFRESH_SPIN_MS = 800 + +private const val SOCIAL_UI_SCALE = 1.3f diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcherScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcherScreen.kt new file mode 100644 index 00000000..3f25ad26 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/AccountSwitcherScreen.kt @@ -0,0 +1,146 @@ +package org.polyfrost.polyplus.client.gui + +import androidx.compose.runtime.Composable +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.Screen +import org.polyfrost.oneconfig.internal.OneConfigConfig +import org.polyfrost.oneconfig.internal.ui.compose.BlurRenderer +import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen +import org.polyfrost.oneconfig.internal.ui.themes.Theme +import org.polyfrost.polyplus.client.AccountSwitcherOverlay + +//? if >= 26.1 { +import net.minecraft.client.gui.GuiGraphicsExtractor +//?} + +//? if >= 1.21.10 { +import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx +//?} + +//? if < 26.1 && > 1.8.9 { +/*import net.minecraft.client.gui.GuiGraphics +*///?} + +//? if = 1.8.9 { +/*import org.polyfrost.polyplus.client.social.execute +import org.lwjgl.input.Keyboard +*///?} + +class AccountSwitcherScreen(internal val previousScreen: Screen?) : ComposeScreen(RenderMode.CONTINUOUS) { + private var firstFrameDrawn = false + private val openedAt = System.currentTimeMillis() + + //? if > 1.8.9 { + override fun shouldCloseOnEsc(): Boolean = true + + private fun inGame(): Boolean = Minecraft.getInstance().level != null + //?} else { + /*private fun inGame(): Boolean = Minecraft.getInstance().world != null + + private var escaping = false + + override fun handleKeyboard() { + escaping = Keyboard.getEventKeyState() && Keyboard.getEventKey() == Keyboard.KEY_ESCAPE + try { + super.handleKeyboard() + } finally { + escaping = false + } + } + + override fun removed() { + super.removed() + if (escaping) Minecraft.getInstance().tell { AccountSwitcherOverlay.restoreAfterEscape(previousScreen) } + } + + override fun render(mouseX: Int, mouseY: Int, tickDelta: Float) { + if (inGame() && OneConfigConfig.enableBackgroundBlur) BlurRenderer.drawBlur(worldBlurRadius()) + super.render(mouseX, mouseY, tickDelta) + } + *///?} + + /** Mirrors OneConfig's own fullscreen blur fade-in (see OneConfigUIScreen). */ + private fun worldBlurRadius(): Float { + val progress = (System.currentTimeMillis() - openedAt).toFloat() / OPEN_ANIMATION_MS + return FULLSCREEN_BLUR_RADIUS * progress.coerceIn(0f, 1f) + } + + private companion object { + private const val FULLSCREEN_BLUR_RADIUS = 8f + private const val OPEN_ANIMATION_MS = 250L + } + + //? if <26.1 && > 1.8.9 { + /*override fun render(ctx: GuiGraphics, mouseX: Int, mouseY: Int, tickDelta: Float) { + if (!inGame()) { + MenuBackgroundPass.enqueue(true) + renderPanorama(ctx, tickDelta) + if (firstFrameDrawn) { + val gameRenderer = Minecraft.getInstance().gameRenderer + //? if <1.21.4 { + gameRenderer.processBlurEffect(tickDelta) + //?} else { + /^gameRenderer.processBlurEffect() + ^///?} + } + } else if (OneConfigConfig.enableBackgroundBlur) { + //? if >= 1.21.10 { + /^if (SkiaCtx.isDeferredComposeBackend) { + ctx.nextStratum() + ctx.blurBeforeThisStratum() + SkiaCtx.requestBlurSnapshot() + } + ^///?} + BlurRenderer.drawBlur(worldBlurRadius()) + } + super.render(ctx, mouseX, mouseY, tickDelta) + firstFrameDrawn = true + } + + override fun renderBackground(ctx: GuiGraphics, mouseX: Int, mouseY: Int, tickDelta: Float) { + if (!inGame()) return + super.renderBackground(ctx, mouseX, mouseY, tickDelta) + } + *///?} else if > 1.8.9 { + override fun extractRenderState(ctx: GuiGraphicsExtractor, mouseX: Int, mouseY: Int, tickDelta: Float) { + if (!inGame()) { + MenuBackgroundPass.enqueue(true) + Minecraft.getInstance().gameRenderer + //? if >= 26.2 { + .panorama() + .extractRenderState(ctx, width, height) + //?} else { + /*.getPanorama() + .extractRenderState(ctx, width, height, true) + *///?} + ctx.blurBeforeThisStratum() + } else if (OneConfigConfig.enableBackgroundBlur) { + if (SkiaCtx.isDeferredComposeBackend) { + ctx.nextStratum() + ctx.blurBeforeThisStratum() + SkiaCtx.requestBlurSnapshot() + } + BlurRenderer.drawBlur(worldBlurRadius()) + } + super.extractRenderState(ctx, mouseX, mouseY, tickDelta) + } + + override fun extractBackground(ctx: GuiGraphicsExtractor, mouseX: Int, mouseY: Int, tickDelta: Float) { + if (!inGame()) return + super.extractBackground(ctx, mouseX, mouseY, tickDelta) + } + //?} + + //? if > 1.8.9 { + override fun onClose() { + Minecraft.getInstance().execute { AccountSwitcherOverlay.close() } + } + //?} + + @Composable + override fun compose() { + Theme { + AccountSwitcherContent(screen = this, onClose = { Minecraft.getInstance().execute { AccountSwitcherOverlay.close() } }) + } + } +} 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..f85a7208 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -17,6 +17,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.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -115,6 +116,7 @@ import org.polyfrost.oneconfig.internal.ui.themes.MinecraftDark import org.polyfrost.oneconfig.internal.ui.themes.MinecraftLight import org.polyfrost.oneconfig.internal.ui.themes.PolyGlassLight import org.polyfrost.oneconfig.internal.ui.themes.Theme +import org.polyfrost.polyplus.client.AccountSwitcherOverlay import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.PolyPlusConfig import org.polyfrost.polyplus.client.PolyPlusMainMenuConfig @@ -126,15 +128,12 @@ import org.polyfrost.polyplus.client.features.OnboardingFeatures import org.polyfrost.polyplus.client.gui.preview.PlayerPreview import org.polyfrost.polyplus.client.host.HostWorldManager import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource -import org.polyfrost.polyplus.client.launcher.MicrosoftAuth -import org.polyfrost.polyplus.client.launcher.MicrosoftAuthException import org.polyfrost.polyplus.client.launcher.OneLauncherAccounts import org.polyfrost.polyplus.client.network.http.responses.BodySlot import org.polyfrost.polyplus.client.social.FriendsRepository import org.polyfrost.polyplus.client.social.GroupsRepository import org.polyfrost.polyplus.client.social.SocialOverlay import org.polyfrost.polyplus.client.utils.ClientPlatform -import org.polyfrost.polyplus.client.utils.runSuspendCatching import org.polyfrost.polyplus.privacy.PrivacyConsent import java.awt.image.BufferedImage import java.io.IOException @@ -150,7 +149,6 @@ import java.util.concurrent.Executors import javax.imageio.ImageIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -622,10 +620,9 @@ private class MenuActions( val connect: (ServerData) -> Unit, ) -private const val ASSETS = "assets/polyplus/mainmenu/" +const val ASSETS = "assets/polyplus/mainmenu/" private const val ONBOARDING_ASSETS = "assets/polyplus/onboarding/" -private val PageBackground = Color(0xFF11171C) private val PanelBackground: Color @Composable get() = LocalTheme.current.componentBackground.copy(alpha = 0.5f) private val ServerIconBackground = Color(0x33FFFFFF) @@ -883,7 +880,7 @@ private fun RightColumn(modifier: Modifier, screen: Screen) { } } if (!PolyPlusMainMenuConfig.hideMainMenuAltManager) { - AccountPill(name = playerName()) + AccountPill(name = playerName(), screen = screen) } if (!PolyPlusMainMenuConfig.hideMainMenuHostWorld && PrivacyConsent.allowsOnlineServices()) { HostWorldButton(screen) @@ -1248,132 +1245,19 @@ private fun ServerRow( } @Composable -private fun AccountPill(name: String) { - val scope = rememberCoroutineScope() +private fun AccountPill(name: String, screen: Screen) { + val actions = rememberAccountActions() var open by remember { mutableStateOf(false) } - var accounts by remember { mutableStateOf?>(null) } - var busy by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(null) } - var errorSteps by remember { mutableStateOf?>(null) } - var loginSession by remember { mutableStateOf(null) } - var loginJob by remember { mutableStateOf(null) } var pillSize by remember { mutableStateOf(IntSize.Zero) } - - suspend fun reload() { - accounts = withContext(Dispatchers.IO) { OneLauncherAccounts.list() } + val accounts = remember(actions.accounts) { + actions.accounts?.sortedWith(compareByDescending { it.active }.thenBy { it.username.lowercase() }) } + val busy = actions.busy LaunchedEffect(open) { if (open) { - error = null - errorSteps = null - reload() - } - } - - val onSwitch: (OneLauncherAccounts.Account) -> Unit = { account -> - if (busy == null && !account.active) { - scope.launch { - busy = "Switching…" - error = null - errorSteps = null - val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.switchTo(account.id) } - if (!ok) error = "Couldn't switch to that account" - reload() - busy = null - } - } - } - val onAddMicrosoft: () -> Unit = { - if (busy == null) { - loginJob = scope.launch { - error = null - errorSteps = null - busy = "Starting Microsoft sign-in…" - // cancellation can discard beginLogin's result or skip finishLogin entirely, - // so this job owns the session's server until it ends - var started: MicrosoftAuth.MicrosoftLoginSession? = null - try { - val session = runSuspendCatching { - withContext(Dispatchers.IO) { OneLauncherAccounts.beginLogin().also { started = it } } - } - .onFailure { - error = it.message ?: "Couldn't start sign-in" - errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } - } - .getOrNull() - if (session == null) { - busy = null - loginJob = null - return@launch - } - loginSession = session - ClientPlatform.openUri(session.browserAuthUrl) - busy = "Waiting for you to finish signing in…" - runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.finishLogin(session) } } - .onFailure { - error = it.message ?: "Microsoft sign-in failed" - errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } - } - } finally { - started?.let { runCatching { OneLauncherAccounts.cancelLogin(it) } } - } - loginSession = null - loginJob = null - reload() - busy = null - } - } - } - val onCancelLogin: () -> Unit = { - loginJob?.cancel() - loginSession?.let { runCatching { OneLauncherAccounts.cancelLogin(it) } } - loginJob = null - loginSession = null - busy = null - error = null - errorSteps = null - } - val onRefresh: (OneLauncherAccounts.Account) -> Unit = { account -> - if (busy == null) { - scope.launch { - busy = "Refreshing session…" - error = null - errorSteps = null - runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.refresh(account.id) } } - .onFailure { - error = it.message ?: "Couldn't refresh that account" - errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } - } - reload() - busy = null - } - } - } - val onAddOffline: (String) -> Unit = { username -> - if (busy == null) { - scope.launch { - busy = "Adding account…" - error = null - errorSteps = null - runSuspendCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addOffline(username) } } - .onFailure { error = it.message ?: "Couldn't add that account" } - reload() - busy = null - } - } - } - val onRemove: (OneLauncherAccounts.Account) -> Unit = { account -> - if (busy == null) { - scope.launch { - busy = "Removing account…" - error = null - errorSteps = null - val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.remove(account.id) } - if (!ok) error = "Couldn't remove that account" - reload() - busy = null - } + actions.clearError() + actions.reload() } } @@ -1433,28 +1317,15 @@ private fun AccountPill(name: String) { panelWidth = with(LocalDensity.current) { pillSize.width.toDp() }, accounts = accounts, busy = busy, - error = error, - errorSteps = errorSteps, - loginSession = loginSession, - onSwitch = onSwitch, - onRemove = onRemove, - onRefresh = onRefresh, - onAddMicrosoft = onAddMicrosoft, - onCancelLogin = onCancelLogin, - onAddOffline = onAddOffline, + error = actions.error, + errorSteps = actions.errorSteps, + onSwitch = actions::switchTo, + onRemove = actions::remove, + onRefresh = actions::refresh, + screen = screen, ) } } - val session = loginSession - if (session != null) { - MicrosoftLoginPopup( - code = session.userCode, - verificationUri = session.verificationUri, - browserAuthUrl = session.browserAuthUrl, - status = busy, - onCancel = onCancelLogin, - ) - } } } @@ -1465,22 +1336,18 @@ private fun AccountSwitcherPanel( busy: String?, error: String?, errorSteps: List?, - loginSession: MicrosoftAuth.MicrosoftLoginSession?, onSwitch: (OneLauncherAccounts.Account) -> Unit, onRemove: (OneLauncherAccounts.Account) -> Unit, onRefresh: (OneLauncherAccounts.Account) -> Unit, - onAddMicrosoft: () -> Unit, - onCancelLogin: () -> Unit, - onAddOffline: (String) -> Unit, + screen: Screen, ) { - var offlineEntry by remember { mutableStateOf(false) } val idle = busy == null Column( modifier = Modifier .width(panelWidth) .clip(PanelShape) - .background(PageBackground.copy(alpha = 0.96f)) + .background(LocalTheme.current.pageBackground.copy(alpha = 0.8f)) .border(SocialPanelBorderWidth, SocialPanelBorderBrush, PanelShape) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -1530,56 +1397,43 @@ private fun AccountSwitcherPanel( Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SocialPanelBorderBrush)) - if (offlineEntry) { - OfflineAccountEntry( - enabled = idle, - onSubmit = { username -> - offlineEntry = false - onAddOffline(username) - }, - onCancel = { offlineEntry = false }, - ) - } else { - AddAccountButton( - icon = ASSETS + "log-in-04.svg", - text = "Add Microsoft account", - enabled = idle, - onClick = onAddMicrosoft, - ) - AddAccountButton( - icon = ASSETS + "user-01.svg", - text = "Add offline account", - enabled = idle, - onClick = { offlineEntry = true }, - ) - } + AddAccountButton( + icon = ASSETS + "users-01.svg", + text = "Manage accounts", + enabled = idle, + onClick = { AccountSwitcherOverlay.open(screen) }, + ) + AccountStatusText(busy, error, errorSteps) + } +} - if (busy != null) { - SocialText(busy, fontSize = 12.sp, color = SocialTextSecondary, modifier = Modifier.align(Alignment.Start), textAlign = TextAlign.Center) - } - if (error != null) { - SocialText(error, fontSize = 12.sp, color = SocialDangerColor, modifier = Modifier.align(Alignment.Start), textAlign = TextAlign.Center) - } - if (!errorSteps.isNullOrEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - SocialText( - "What you can do:", - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.align(Alignment.Start), - textAlign = TextAlign.Center, - ) - errorSteps.forEach { step -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - SocialText("•", fontSize = 12.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) - SocialText(step, fontSize = 12.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) - } +@Composable +internal fun ColumnScope.AccountStatusText(busy: String?, error: String?, errorSteps: List?) { +// if (busy != null) { +// SocialText(busy, fontSize = 12.sp, color = SocialTextSecondary, modifier = Modifier.align(Alignment.Start), textAlign = TextAlign.Center) +// } + if (error != null) { + SocialText(error, fontSize = 12.sp, color = SocialDangerColor, modifier = Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) + } + if (!errorSteps.isNullOrEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + SocialText( + "What you can do:", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + ) + errorSteps.forEach { step -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + SocialText("•", fontSize = 12.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) + SocialText(step, fontSize = 12.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) } } } @@ -1587,7 +1441,7 @@ private fun AccountSwitcherPanel( } @Composable -private fun AccountRow( +internal fun AccountRow( account: OneLauncherAccounts.Account, enabled: Boolean, onClick: () -> Unit, @@ -1667,7 +1521,7 @@ private fun AccountRow( } @Composable -private fun AddAccountButton( +fun AddAccountButton( icon: String, text: String, enabled: Boolean, @@ -1677,12 +1531,12 @@ private fun AddAccountButton( val hovered by interaction.collectIsHoveredAsState() Row( modifier = Modifier - .fillMaxWidth() .clip(ppShape(8.dp)) .background(if (hovered && enabled) LocalTheme.current.componentBackground.copy(alpha = 0.4f) else Color.Transparent) .hoverable(interaction) .then(if (enabled) Modifier.clickableWithSound(onClick) else Modifier) .alpha(if (enabled) 1f else 0.5f) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -1693,55 +1547,7 @@ private fun AddAccountButton( } @Composable -private fun OfflineAccountEntry( - enabled: Boolean, - onSubmit: (String) -> Unit, - onCancel: () -> Unit, -) { - var value by remember { mutableStateOf("") } - val bodyFont = LocalTheme.current.typography.family - val valid = value.trim().length in 3..16 - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .weight(1f) - .height(34.dp) - .clip(ppShape(6.dp)) - .background(LocalTheme.current.componentBackground.copy(alpha = 0.5f)) - .border(SocialPanelBorderWidth, SocialPanelBorderBrush, ppShape(6.dp)) - .padding(horizontal = 10.dp), - contentAlignment = Alignment.CenterStart, - ) { - BasicTextField( - value = value, - onValueChange = { if (it.length <= 16) value = it }, - modifier = Modifier.trackTextInputFocus(), - singleLine = true, - enabled = enabled, - textStyle = TextStyle(color = SocialTextPrimary, fontSize = 13.sp, fontFamily = bodyFont), - cursorBrush = SolidColor(Accent), - decorationBox = { inner -> - if (value.isEmpty()) { - SocialText("Username", fontSize = 13.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) - } - inner() - }, - ) - } - AccountActionIcon(ASSETS + "check-circle.svg", SocialSuccessColor, enabled && valid) { - if (valid) onSubmit(value.trim()) - } - AccountActionIcon(ASSETS + "x-close.svg", SocialTextSecondary, enabled, onCancel) - } -} - -@Composable -private fun MicrosoftLoginPopup( +internal fun MicrosoftLoginPopup( code: String, verificationUri: String, browserAuthUrl: String, @@ -1753,7 +1559,7 @@ private fun MicrosoftLoginPopup( modifier = Modifier .width(380.dp) .clip(PanelShape) - .background(PageBackground.copy(alpha = 0.96f)) + .background(LocalTheme.current.pageBackground.copy(alpha = 0.8f)) .border(SocialPanelBorderWidth, SocialPanelBorderBrush, PanelShape) .swallowClicks() .padding(horizontal = 22.dp, vertical = 24.dp), @@ -1837,6 +1643,87 @@ private fun MicrosoftLoginPopup( } } +@Composable +internal fun OfflineAccountPopup( + busy: String?, + error: String?, + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var username by remember { mutableStateOf("") } + val trimmed = username.trim() + val validChars = trimmed.all { it.isLetterOrDigit() && it.code < 128 || it == '_' } + val valid = trimmed.length in 3..16 && validChars + val idle = busy == null + // the error belongs to the last name tried, so hide it once the user edits + var submitted by remember { mutableStateOf(null) } + val submit = { + if (valid && idle) { + submitted = trimmed + onSubmit(trimmed) + } + } + + SocialModalScrim({ if (idle) onCancel() }) { + Column( + modifier = Modifier + .width(380.dp) + .clip(PanelShape) + .background(LocalTheme.current.pageBackground.copy(alpha = 0.8f)) + .border(SocialPanelBorderWidth, SocialPanelBorderBrush, PanelShape) + .swallowClicks() + .padding(horizontal = 22.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SocialText("Add offline account", fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = Outfit, textAlign = TextAlign.Center) + SocialTextField( + value = username, + onValueChange = { username = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "Username", + leadingIcon = ASSETS + "user-01.svg", + maxLength = 16, + onSubmit = submit, + ) + val hint = when { + error != null && submitted == trimmed -> error + trimmed.isNotEmpty() && !validChars -> "Only letters, digits, and underscores" + trimmed.isNotEmpty() && trimmed.length < 3 -> "At least 3 characters" + else -> null + } + if (hint != null) { + SocialText(hint, fontSize = 12.sp, color = SocialDangerColor, textAlign = TextAlign.Center) + } + if (busy != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LoadingSpinner(Modifier.size(14.dp)) + SocialText(busy, fontSize = 13.sp, color = SocialTextSecondary, textAlign = TextAlign.Center) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + LoginModalButton( + label = "Cancel", + icon = ASSETS + "x-close.svg", + filled = false, + modifier = Modifier.weight(1f).alpha(if (idle) 1f else 0.5f), + onClick = { if (idle) onCancel() }, + ) + LoginModalButton( + label = "Add account", + icon = ASSETS + "plus.svg", + filled = true, + modifier = Modifier.weight(1f).alpha(if (valid && idle) 1f else 0.5f), + onClick = submit, + ) + } + } + } +} + @Composable private fun LoginModalButton( label: String, @@ -1881,7 +1768,7 @@ private fun LoadingSpinner(modifier: Modifier) { } @Composable -private fun AccountActionIcon(icon: String, color: Color, enabled: Boolean, onClick: () -> Unit) { +internal fun AccountActionIcon(icon: String, color: Color, enabled: Boolean, onClick: () -> Unit) { Box( modifier = Modifier .size(30.dp) @@ -2018,7 +1905,7 @@ private fun rememberFavicon(icon: String?): ImageBitmap? = remember(icon) { private fun rememberRaster(path: String): ImageBitmap? = if (MainMenuRasterAssets.ready) MainMenuRasterAssets.cached(path) else null -private fun playerName(): String = runCatching { +fun playerName(): String = runCatching { Minecraft.getInstance().user.name }.getOrDefault("Player") diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt index 34cca5a8..a7502091 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt @@ -25,7 +25,8 @@ object OneLauncherAccounts { ?: store.defaultUser?.let { LauncherAccountStore.parseUuid(it) } ?: store.users.keys.firstNotNullOfOrNull { LauncherAccountStore.parseUuid(it) } - return (store.users.values + listOfNotNull(session)).mapNotNull { stored -> + // Store order is add order (newest last); the unsaved launch account predates all of them + return (listOfNotNull(session) + store.users.values).mapNotNull { stored -> val id = LauncherAccountStore.parseUuid(stored.id) ?: return@mapNotNull null val microsoft = LauncherAccountStore.isMicrosoft(stored) Account( @@ -36,7 +37,7 @@ object OneLauncherAccounts { // The launch account has no refresh token so it can never be refreshed expired = microsoft && stored !== session && LauncherAccountStore.isExpired(stored.expires), ) - }.sortedWith(compareByDescending { it.active }.thenBy { it.username.lowercase() }) + } } fun switchTo(id: UUID): Boolean { diff --git a/src/main/resources/assets/polyplus/mainmenu/alert-triangle.svg b/src/main/resources/assets/polyplus/mainmenu/alert-triangle.svg new file mode 100644 index 00000000..9ccdca22 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/alert-triangle.svg @@ -0,0 +1,5 @@ + + + + +