From ec6c1a2613a05894a8145c8f7aa4c74effa762dd Mon Sep 17 00:00:00 2001 From: Danielle Voznyy Date: Thu, 7 Mar 2024 14:39:17 -0500 Subject: [PATCH] Fix: Image reading state could cause errors when switching menus fast on downloading for the first time Fix: Not creating configs on first launch Fix: Handle more I/O exceptions instead of outright crashing Feat: Support deleting instances --- gradle.properties | 2 +- .../kotlin/com/mineinabyss/launchy/Main.kt | 3 +- .../com/mineinabyss/launchy/data/Dirs.kt | 5 +- .../mineinabyss/launchy/data/config/Config.kt | 40 ++---------- .../launchy/data/config/GameInstance.kt | 7 +-- .../launchy/data/config/GameInstanceConfig.kt | 51 ++++++++-------- .../launchy/data/config/ModpackUserConfig.kt | 7 +-- .../launchy/data/config/PlayerProfile.kt | 24 ++++++-- .../mineinabyss/launchy/logic/Downloader.kt | 2 +- .../com/mineinabyss/launchy/logic/Helpers.kt | 4 +- .../mineinabyss/launchy/logic/Instances.kt | 23 +++++++ .../launchy/logic/ModDownloader.kt | 2 +- .../mineinabyss/launchy/state/LaunchyState.kt | 3 +- .../launchy/ui/dialogs/AuthDialog.kt | 5 -- .../launchy/ui/dialogs/SelectJVMDialog.kt | 7 ++- .../launchy/ui/elements/AnimatedTab.kt | 18 ++++++ .../launchy/ui/elements/ComfyContent.kt | 27 ++++++-- .../launchy/ui/elements/PlayerAvatar.kt | 11 +--- .../launchy/ui/elements/Typography.kt | 16 +++++ .../launchy/ui/screens/home/ModpackCard.kt | 7 ++- .../screens/home/newinstance/NewInstance.kt | 33 +++++----- .../screens/home/settings/SettingsScreen.kt | 30 ++++----- .../screens/modpack/main/MainScreenImages.kt | 11 ++-- .../modpack/main/buttons/InstallButton.kt | 7 +-- .../modpack/main/buttons/PlayButton.kt | 7 +-- .../ui/screens/modpack/settings/InfoBar.kt | 3 +- .../settings/InstanceSettingsScreen.kt | 61 ++++++++++++++++++- 27 files changed, 258 insertions(+), 158 deletions(-) create mode 100644 src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt create mode 100644 src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt create mode 100644 src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt diff --git a/gradle.properties b/gradle.properties index 0fd326a..8e66c9e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ group=com.mineinabyss -version=2.0.0-alpha.4 +version=2.0.0-alpha.5 idofrontVersion=0.22.3 diff --git a/src/main/kotlin/com/mineinabyss/launchy/Main.kt b/src/main/kotlin/com/mineinabyss/launchy/Main.kt index 4381549..4c95c14 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/Main.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/Main.kt @@ -37,7 +37,8 @@ fun main() { val icon = painterResource("icon.png") val launchyState by produceState(null) { Dirs.createDirs() - val config = Config.read() + Dirs.createConfigFiles() + val config = Config.read().getOrElse { Config() } val instances = GameInstance.readAll(Dirs.modpackConfigsDir) value = LaunchyState(config, instances) } diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt b/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt index 28485d2..c26722b 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt @@ -1,7 +1,7 @@ package com.mineinabyss.launchy.data import com.mineinabyss.launchy.util.OS -import java.util.UUID +import java.util.* import kotlin.io.path.* object Dirs { @@ -36,7 +36,6 @@ object Dirs { fun avatar(uuid: UUID) = imageCache / "avatar-$uuid" val configFile = config / "mia-launcher.yml" - val versionsFile = config / "mia-versions.yml" val modpackConfigsDir = (config / "modpacks") @@ -56,7 +55,5 @@ object Dirs { fun createConfigFiles() { if (configFile.notExists()) configFile.createFile().writeText("{}") - if (versionsFile.notExists()) - versionsFile.createFile().writeText("{}") } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt index b99e43a..08b9f2b 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt @@ -4,10 +4,8 @@ import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.Formats import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import java.io.* -import java.nio.file.Path -import java.util.zip.ZipFile -import kotlin.io.path.* +import kotlin.io.path.inputStream +import kotlin.io.path.writeText @Serializable @@ -26,38 +24,8 @@ data class Config( } companion object { - fun read() = + fun read(): Result = runCatching { Formats.yaml.decodeFromStream(serializer(), Dirs.configFile.inputStream()) + }.onFailure { it.printStackTrace() } } } - -@Throws(IOException::class) -fun unzip(zipFilePath: Path, destDirectory: Path) { - if (destDirectory.notExists()) destDirectory.createDirectories() - - ZipFile(zipFilePath.toFile()).use { zip -> - zip.entries().asSequence().forEach { entry -> - zip.getInputStream(entry).use { input -> - val filePath = destDirectory / entry.name - filePath.createParentDirectories() - if (!entry.isDirectory) extractFile(input, filePath) - else { - if (filePath.notExists()) filePath.createDirectory() - } - } - } - } -} - -@Throws(IOException::class) -fun extractFile(inputStream: InputStream, destFilePath: Path) { - val bufferSize = 4096 - val buffer = BufferedOutputStream(destFilePath.outputStream()) - val bytes = ByteArray(bufferSize) - var read: Int - while (inputStream.read(bytes).also { read = it } != -1) { - buffer.write(bytes, 0, read) - } - buffer.close() -} - diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt index 4e8d2eb..4312638 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt @@ -1,6 +1,5 @@ package com.mineinabyss.launchy.data.config -import com.charleskorn.kaml.decodeFromStream import com.charleskorn.kaml.encodeToStream import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.Formats @@ -20,16 +19,14 @@ class GameInstance( require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } } - val config: GameInstanceConfig = GameInstanceConfig.read(configDir / "instance.yml") + val config: GameInstanceConfig = GameInstanceConfig.read(configDir / "instance.yml").getOrThrow() val minecraftDir = config.overrideMinecraftDir?.let { Path(it) } ?: Dirs.modpackDir(configDir.name) val userConfigFile = (configDir / "config.yml") suspend fun createModpackState(): ModpackState? { - val userConfig = - if (userConfigFile.exists()) Formats.yaml.decodeFromStream(userConfigFile.inputStream()) - else ModpackUserConfig() + val userConfig = ModpackUserConfig.load(userConfigFile).getOrNull() ?: ModpackUserConfig() val modpack = config.source.loadInstance(this) .getOrElse { dialog = Dialog.Error( diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt index 5d55b32..dec75ce 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt @@ -1,7 +1,8 @@ package com.mineinabyss.launchy.data.config import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.loadImageBitmap import com.mineinabyss.launchy.data.Dirs @@ -9,14 +10,12 @@ import com.mineinabyss.launchy.data.Formats import com.mineinabyss.launchy.data.modpacks.source.PackSource import com.mineinabyss.launchy.logic.Downloader import com.mineinabyss.launchy.state.LaunchyState -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import java.nio.file.Path import kotlin.io.path.div import kotlin.io.path.inputStream -import kotlin.time.Duration.Companion.seconds @Serializable @OptIn(ExperimentalStdlibApi::class) @@ -37,43 +36,47 @@ data class GameInstanceConfig( val logoPath = Dirs.imageCache / "icon-${logoURL.hashCode().toHexString()}" @Transient - private var cachedBackground: BitmapPainter? = null + private var cachedBackground = mutableStateOf(null) @Transient - private var cachedLogo: BitmapPainter? = null + private var cachedLogo = mutableStateOf(null) - suspend fun loadBackgroundImage(): BitmapPainter { - cachedBackground?.let { return it } - Downloader.download(backgroundURL, backgroundPath, override = false) - val painter = BitmapPainter(loadImageBitmap(backgroundPath.inputStream())) - cachedBackground = painter - return painter + + private suspend fun loadBackground() { + if (cachedBackground.value != null) return + runCatching { + Downloader.download(backgroundURL, backgroundPath, override = false) + val painter = BitmapPainter(loadImageBitmap(backgroundPath.inputStream())) + cachedBackground.value = painter + }.onFailure { it.printStackTrace() } } - suspend fun loadLogo(): BitmapPainter { - cachedLogo?.let { return it } - Downloader.download(logoURL, logoPath, override = false) - val painter = BitmapPainter(loadImageBitmap(logoPath.inputStream())) - cachedLogo = painter - return painter + private suspend fun loadLogo() { + if (cachedLogo.value != null) return + runCatching { + Downloader.download(logoURL, logoPath, override = false) + val painter = BitmapPainter(loadImageBitmap(logoPath.inputStream())) + cachedLogo.value = painter + }.onFailure { it.printStackTrace() } } @Composable - fun produceBackgroundState(state: LaunchyState) = produceState(cachedBackground) { - state.downloadContext.launch { - value = loadBackgroundImage() + fun getBackground(state: LaunchyState) = remember { + cachedBackground.also { + if (it.value == null) state.ioScope.launch { loadBackground() } } } @Composable - fun produceLogoState(state: LaunchyState) = produceState(cachedLogo) { - state.downloadContext.launch { - value = loadLogo() + fun getLogo(state: LaunchyState) = remember { + cachedLogo.also { + if (it.value == null) state.ioScope.launch { loadLogo() } } } companion object { - fun read(path: Path) = + fun read(path: Path) = runCatching { Formats.yaml.decodeFromStream(serializer(), path.inputStream()) + }.onFailure { it.printStackTrace() } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt index 847790a..f11d03c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt @@ -26,10 +26,9 @@ data class ModpackUserConfig( } companion object { - fun load(packConfigDir: Path): ModpackUserConfig { - val file = packConfigDir / "config.yml" - return if (file.exists()) Formats.yaml.decodeFromStream(file.inputStream()) + fun load(file: Path): Result = runCatching { + return@runCatching if (file.exists()) Formats.yaml.decodeFromStream(file.inputStream()) else ModpackUserConfig() - } + }.onFailure { it.printStackTrace() } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt index 0b7fabb..5dc503e 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt @@ -1,5 +1,9 @@ package com.mineinabyss.launchy.data.config +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.loadImageBitmap @@ -9,6 +13,7 @@ import com.mineinabyss.launchy.logic.Downloader import com.mineinabyss.launchy.state.LaunchyState import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import java.util.* import kotlin.io.path.inputStream @@ -17,11 +22,18 @@ data class PlayerProfile( val name: String, val uuid: @Serializable(with = UUIDSerializer::class) UUID, ) { - suspend fun getAvatar(state: LaunchyState): BitmapPainter { - val avatarPath = Dirs.avatar(uuid) - state.downloadContext.launch { - Downloader.downloadAvatar(uuid) - }.join() - return BitmapPainter(loadImageBitmap(avatarPath.inputStream()), filterQuality = FilterQuality.None) + @Transient + private val avatar = mutableStateOf(null) + + @Composable + fun getAvatar(state: LaunchyState): MutableState = remember { + avatar.also { + if (it.value != null) return@also + state.ioScope.launch { + Downloader.downloadAvatar(uuid) + it.value = + BitmapPainter(loadImageBitmap(Dirs.avatar(uuid).inputStream()), filterQuality = FilterQuality.None) + } + } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt index 6581ebf..c7afcab 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt @@ -27,7 +27,7 @@ object Downloader { } suspend fun downloadAvatar(uuid: UUID) { - download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid), override = false) + download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid)) } @OptIn(ExperimentalStdlibApi::class) diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt index 47a8b5e..c727342 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt @@ -3,8 +3,8 @@ package com.mineinabyss.launchy.logic import com.mineinabyss.launchy.ui.screens.Dialog import com.mineinabyss.launchy.ui.screens.dialog -fun Result.showDialogOnError(): Result { - onFailure { dialog = Dialog.fromException(it) } +fun Result.showDialogOnError(title: String? = null): Result { + onFailure { dialog = Dialog.fromException(it, title) } return this } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt new file mode 100644 index 0000000..b277ac9 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt @@ -0,0 +1,23 @@ +package com.mineinabyss.launchy.logic + +import com.mineinabyss.launchy.data.config.GameInstance +import com.mineinabyss.launchy.state.InProgressTask +import com.mineinabyss.launchy.state.LaunchyState +import kotlinx.coroutines.launch +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively + +object Instances { + @OptIn(ExperimentalPathApi::class) + fun GameInstance.delete(state: LaunchyState) { + try { + state.inProgressTasks["deleteInstance"] = InProgressTask("Deleting instance ${config.name}") + state.gameInstances.remove(this) + state.ioScope.launch { + configDir.deleteRecursively() + } + } finally { + state.inProgressTasks.remove("deleteInstance") + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt index 37c9722..7eea68d 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt @@ -141,7 +141,7 @@ object ModDownloader { toggles.checkNonDownloadedMods() val modDownloads = launch { queued.downloads.map { mod -> - state.downloadContext.launch { + state.ioScope.launch { download(state, mod) toggles.checkNonDownloadedMods() } diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt index b2e14c8..ccfa71f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt @@ -15,7 +15,8 @@ class LaunchyState( private val instances: List ) { @OptIn(ExperimentalCoroutinesApi::class) - val downloadContext = CoroutineScope(Dispatchers.IO.limitedParallelism(10)) + val ioContext = Dispatchers.IO.limitedParallelism(10) + val ioScope = CoroutineScope(ioContext) val profile = ProfileState(config) var modpackState: ModpackState? by mutableStateOf(null) private val launchedProcesses = mutableStateMapOf() diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt index d2233a1..1938bcd 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt @@ -3,13 +3,10 @@ package com.mineinabyss.launchy.ui.dialogs import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.Icon import androidx.compose.material.LocalTextStyle import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,7 +17,6 @@ import androidx.compose.ui.unit.sp import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.logic.Browser import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.elements.PrimaryIconButtonColors import com.mineinabyss.launchy.ui.screens.Dialog import com.mineinabyss.launchy.ui.screens.dialog @@ -57,7 +53,6 @@ fun AuthDialog( ) { append("microsoft.com/link") } - appendInlineContent("copyIcon", "[copy]") pop() append(" and enter the code ${state.profile.authCode}") diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt index 967ceae..e77f544 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt @@ -7,7 +7,10 @@ import androidx.compose.runtime.rememberCoroutineScope import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.logic.Downloader import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.screens.* +import com.mineinabyss.launchy.ui.screens.Dialog +import com.mineinabyss.launchy.ui.screens.Screen +import com.mineinabyss.launchy.ui.screens.dialog +import com.mineinabyss.launchy.ui.screens.screen import kotlinx.coroutines.launch @Composable @@ -18,7 +21,7 @@ fun SelectJVMDialog() { title = { Text("Install java", style = LocalTextStyle.current) }, onAccept = { dialog = Dialog.None - state.downloadContext.launch { + state.ioScope.launch { val jdkPath = runCatching { Downloader.installJDK(state) }.getOrElse { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt new file mode 100644 index 0000000..87d1e88 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt @@ -0,0 +1,18 @@ +package com.mineinabyss.launchy.ui.elements + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.Composable + +@Composable +fun AnimatedTab(visible: Boolean, content: @Composable () -> Unit) { + androidx.compose.animation.AnimatedVisibility( + visible, + enter = slideInHorizontally() + fadeIn(), + exit = slideOutHorizontally() + fadeOut() + ) { + content() + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt index e7c2eb0..6f33a9f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt @@ -1,18 +1,25 @@ package com.mineinabyss.launchy.ui.elements -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.ui.screens.screen @Composable fun ComfyWidth( content: @Composable () -> Unit ) { + val endDp = if (screen.showSidebar) 16.dp else 0.dp Box( - Modifier.fillMaxWidth().padding(end = 16.dp, top = 16.dp), + Modifier.fillMaxWidth().padding(end = endDp), contentAlignment = androidx.compose.ui.Alignment.Center ) { Box(Modifier.width(800.dp)) { @@ -21,17 +28,27 @@ fun ComfyWidth( } } +@Composable +fun ComfyTitle( + title: String +) = ComfyWidth { + Box(Modifier.padding(top = 16.dp, bottom = 8.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium) + } +} + @Composable fun ComfyContent( - content: @Composable () -> Unit + modifier: Modifier = Modifier, + content: @Composable () -> Unit, ) { ComfyWidth { Surface( tonalElevation = 1.dp, shape = RoundedCornerShape(20.dp), - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).then(modifier) ) { - Box(Modifier.padding(16.dp)) { + Box(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp)) { content() } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt index 5c3ee77..d051d9f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt @@ -1,23 +1,18 @@ package com.mineinabyss.launchy.ui.elements import androidx.compose.foundation.Image -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.data.config.PlayerProfile -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun PlayerAvatar(profile: PlayerProfile, modifier: Modifier = Modifier) { - var avatar: BitmapPainter? by remember { mutableStateOf(null) } val state = LocalLaunchyState - LaunchedEffect(profile) { - avatar = profile.getAvatar(state) - } + val avatar: BitmapPainter? by profile.getAvatar(state) if (avatar != null) Image( painter = avatar!!, contentDescription = "Avatar", diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt new file mode 100644 index 0000000..35ebc69 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt @@ -0,0 +1,16 @@ +package com.mineinabyss.launchy.ui.elements + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun TitleSmall(text: String) { + Box(Modifier.padding(top = 12.dp, bottom = 8.dp)) { + Text(text, style = MaterialTheme.typography.titleSmall) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/ModpackCard.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/ModpackCard.kt index 2142b87..f66e64e 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/ModpackCard.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/ModpackCard.kt @@ -10,10 +10,11 @@ import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.mineinabyss.launchy.LocalLaunchyState @@ -47,7 +48,7 @@ fun InstanceCard( ) { val state = LocalLaunchyState val coroutineScope = rememberCoroutineScope() - val background by config.produceBackgroundState(state) + val background by config.getBackground(state) Card( onClick = { instance ?: return@Card diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt index 8847697..ac23817 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt @@ -1,9 +1,5 @@ package com.mineinabyss.launchy.ui.screens.home.newinstance -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.* import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons @@ -18,8 +14,11 @@ import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.config.GameInstance import com.mineinabyss.launchy.data.config.GameInstanceConfig import com.mineinabyss.launchy.logic.Downloader +import com.mineinabyss.launchy.logic.showDialogOnError import com.mineinabyss.launchy.state.InProgressTask +import com.mineinabyss.launchy.ui.elements.AnimatedTab import com.mineinabyss.launchy.ui.elements.ComfyContent +import com.mineinabyss.launchy.ui.elements.ComfyTitle import com.mineinabyss.launchy.ui.elements.ComfyWidth import com.mineinabyss.launchy.ui.screens.Screen import com.mineinabyss.launchy.ui.screens.home.InstanceCard @@ -55,15 +54,10 @@ fun NewInstance() { } val coroutineScope = rememberCoroutineScope() Box { - androidx.compose.animation.AnimatedVisibility( - visible = selectedTabIndex == 0 && importingInstance == null, - enter = slideInHorizontally() + fadeIn(), - exit = slideOutHorizontally() + fadeOut() - ) { + AnimatedTab(visible = selectedTabIndex == 0 && importingInstance == null) { Column { - ComfyWidth { - Text("Import from link", style = MaterialTheme.typography.headlineMedium) - } + ComfyTitle("Import from link") + ComfyContent { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { var urlText by remember { mutableStateOf("") } @@ -98,6 +92,8 @@ fun NewInstance() { state.inProgressTasks[taskKey] = InProgressTask("Importing cloud instance") val cloudInstance = Downloader.download(urlText, downloadPath).mapCatching { GameInstanceConfig.read(downloadPath) + .showDialogOnError("Failed to read cloud instance") + .getOrThrow() }.getOrElse { urlFailedToParse = true state.inProgressTasks.remove(taskKey) @@ -115,11 +111,7 @@ fun NewInstance() { } } } - androidx.compose.animation.AnimatedVisibility( - importingInstance != null, - enter = slideInHorizontally() + fadeIn(), - exit = slideOutHorizontally() + fadeOut() - ) { + AnimatedTab(importingInstance != null) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ComfyWidth { Text("Confirm import", style = MaterialTheme.typography.headlineMedium) @@ -170,7 +162,12 @@ fun NewInstance() { } ComfyWidth { - importingInstance?.let { InstanceCard(it.copy(name = "Preview"), modifier = Modifier.fillMaxWidth()) } + importingInstance?.let { + InstanceCard( + it.copy(name = "Preview"), + modifier = Modifier.fillMaxWidth() + ) + } } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt index dd69247..e6e61da 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt @@ -1,7 +1,6 @@ package com.mineinabyss.launchy.ui.screens.home.settings import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -21,9 +20,9 @@ import com.darkrockstudios.libraries.mpfilepicker.FilePicker import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.logic.SuggestedJVMArgs -import com.mineinabyss.launchy.state.JvmState import com.mineinabyss.launchy.ui.elements.ComfyContent -import com.mineinabyss.launchy.ui.elements.ComfyWidth +import com.mineinabyss.launchy.ui.elements.ComfyTitle +import com.mineinabyss.launchy.ui.elements.TitleSmall import com.mineinabyss.launchy.util.OS import java.awt.FileDialog import java.awt.Frame @@ -36,14 +35,15 @@ fun SettingsScreen() { val state = LocalLaunchyState val scrollState = rememberScrollState() Column { - ComfyWidth { - Text("Settings", style = MaterialTheme.typography.headlineMedium) - } + ComfyTitle("Settings") ComfyContent { var directoryPickerShown by remember { mutableStateOf(false) } - Column(Modifier.padding(16.dp).verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + Modifier.padding(16.dp).verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { Column { - Text("Hue", style = MaterialTheme.typography.titleSmall) + TitleSmall("Hue") Row { Slider( value = state.preferHue, @@ -61,8 +61,7 @@ fun SettingsScreen() { directoryPickerShown = false }) Column { - Text("Java path", style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(8.dp)) + TitleSmall("Java path") OutlinedTextField( value = state.jvm.javaPath?.toString() ?: "No path selected", readOnly = true, @@ -78,7 +77,7 @@ fun SettingsScreen() { } Column { - Text("Memory", style = MaterialTheme.typography.titleSmall) + TitleSmall("Memory") Row { val memory = state.jvm.userMemoryAllocation ?: SuggestedJVMArgs.memory Slider( @@ -101,7 +100,7 @@ fun SettingsScreen() { } Column { - Text("JVM arguments", style = MaterialTheme.typography.titleSmall) + TitleSmall("JVM arguments") OutlinedTextField( value = state.jvm.userJvmArgs ?: "", enabled = !state.jvm.useRecommendedJvmArgs, @@ -113,7 +112,9 @@ fun SettingsScreen() { ) Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(state.jvm.useRecommendedJvmArgs, onCheckedChange = { state.jvm.useRecommendedJvmArgs = it }) + Checkbox( + state.jvm.useRecommendedJvmArgs, + onCheckedChange = { state.jvm.useRecommendedJvmArgs = it }) Text("Use recommended JVM arguments") } @@ -140,7 +141,7 @@ fun FileDialog( parent: Frame? = null, onCloseRequest: (result: Path?) -> Unit ) { - when(OS.get()) { + when (OS.get()) { OS.WINDOWS -> FilePicker( true, initialDirectory = Dirs.jdks.toString(), @@ -149,6 +150,7 @@ fun FileDialog( ) { file -> onCloseRequest(file?.let { Path(it.path) }) } + else -> AwtWindow( create = { object : FileDialog(parent, "Choose a file", LOAD) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt index 4888ff9..02a4775 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt @@ -1,6 +1,9 @@ package com.mineinabyss.launchy.ui.screens.modpack.main -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -8,12 +11,10 @@ import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowScope @@ -24,7 +25,7 @@ import com.mineinabyss.launchy.ui.screens.LocalModpackState fun BoxScope.BackgroundImage(windowScope: WindowScope) { val pack = LocalModpackState val state = LocalLaunchyState - val background by pack.instance.config.produceBackgroundState(state) + val background by pack.instance.config.getBackground(state) AnimatedVisibility(background != null, enter = fadeIn(), exit = fadeOut()) { if (background == null) return@AnimatedVisibility windowScope.WindowDraggableArea { @@ -77,7 +78,7 @@ fun BoxScope.SlightBackgroundTint(modifier: Modifier = Modifier) { fun LogoLarge(modifier: Modifier) { val state = LocalLaunchyState val pack = LocalModpackState - val painter by pack.instance.config.produceLogoState(state) + val painter by pack.instance.config.getLogo(state) AnimatedVisibility( painter != null, enter = fadeIn() + expandVertically(clip = false) + fadeIn(), diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt index 15c8060..6d05bff 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt @@ -3,12 +3,11 @@ package com.mineinabyss.launchy.ui.screens.modpack.main.buttons import androidx.compose.animation.* import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Download -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -25,7 +24,7 @@ fun InstallButton(enabled: Boolean, modifier: Modifier = Modifier) { PrimaryButton( enabled = enabled, onClick = { - state.downloadContext.launch { + state.ioScope.launch { packState.install(state) } }, diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt index 975e6f2..33f2848 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt @@ -26,7 +26,6 @@ import com.mineinabyss.launchy.ui.elements.PrimaryButtonColors import com.mineinabyss.launchy.ui.elements.SecondaryButtonColors import com.mineinabyss.launchy.ui.screens.Dialog import com.mineinabyss.launchy.ui.screens.dialog -import com.mineinabyss.launchy.ui.screens.snackbarHostState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -66,7 +65,7 @@ fun PlayButton( when { // Assume this means not launched before packState.userAgreedDeps == null -> { - state.downloadContext.launch { + state.ioScope.launch { packState.install(state).join() Launcher.launch(state, packState, state.profile) } @@ -78,13 +77,13 @@ fun PlayButton( acceptText = "Download", declineText = "Skip", onAccept = { - state.downloadContext.launch { + state.ioScope.launch { packState.install(state).join() Launcher.launch(state, packState, state.profile) } }, onDecline = { - state.downloadContext.launch { + state.ioScope.launch { packState.install(state).join() Launcher.launch(state, packState, state.profile) } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt index 9f32455..21c5fa2 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download @@ -28,7 +27,7 @@ import com.mineinabyss.launchy.ui.screens.LocalModpackState import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.InstallButton object InfoBarProperties { - val height = 48.dp + val height = 64.dp } @Composable fun InfoBar() { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt index 5f00c14..ed4b54a 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt @@ -7,17 +7,74 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable +import androidx.compose.material.TextButton +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.data.Constants.SETTINGS_HORIZONTAL_PADDING +import com.mineinabyss.launchy.logic.Instances.delete +import com.mineinabyss.launchy.ui.elements.AnimatedTab +import com.mineinabyss.launchy.ui.elements.ComfyContent +import com.mineinabyss.launchy.ui.elements.ComfyWidth +import com.mineinabyss.launchy.ui.elements.TitleSmall import com.mineinabyss.launchy.ui.screens.LocalModpackState +import com.mineinabyss.launchy.ui.screens.Screen +import com.mineinabyss.launchy.ui.screens.screen @Composable @Preview fun InstanceSettingsScreen() { + val state = LocalModpackState + var selectedTabIndex by remember { mutableStateOf(0) } + ComfyWidth { + Column { + PrimaryTabRow(selectedTabIndex = selectedTabIndex) { + Tab( + text = { Text("Manage Mods") }, + selected = selectedTabIndex == 0, + onClick = { selectedTabIndex = 0 } + ) + Tab( + text = { Text("Options") }, + selected = selectedTabIndex == 1, + onClick = { selectedTabIndex = 1 } + ) + } + Box(Modifier.fillMaxSize()) { + AnimatedTab(selectedTabIndex == 0) { + ModManagement() + } + AnimatedTab(selectedTabIndex == 1) { + OptionsTab() + } + } + } + } +} + +@Composable +fun OptionsTab() { + val state = LocalLaunchyState + val pack = LocalModpackState + + ComfyContent(Modifier.padding(16.dp)) { + Column { + TitleSmall("Danger zone") + TextButton(onClick = { + screen = Screen.Default + pack.instance.delete(state) + }) { + Text("Delete Instance", color = MaterialTheme.colorScheme.primary) + } + } + } +} + +@Composable +fun ModManagement() { val state = LocalModpackState Scaffold( bottomBar = { InfoBar() },