diff --git a/gradle.properties b/gradle.properties index 5d3712a..afcceaa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ group=com.mineinabyss -version=2.0.0-alpha.6 +version=2.0.0-alpha.7 idofrontVersion=0.22.3 diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt b/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt index 170940c..c202b49 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt @@ -1,5 +1,6 @@ package com.mineinabyss.launchy.data +import com.mineinabyss.launchy.logic.urlToFileName import com.mineinabyss.launchy.util.OS import java.util.* import kotlin.io.path.* @@ -34,6 +35,8 @@ object Dirs { val accounts = config / "accounts" + fun tmpCloudInstance(url: String) = tmp / "cloudInstances" / "${urlToFileName(url)}.yml" + fun avatar(uuid: UUID) = imageCache / "avatar-$uuid" val configFile = config / "mia-launcher.yml" 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 4312638..a802a90 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt @@ -1,42 +1,64 @@ package com.mineinabyss.launchy.data.config +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.charleskorn.kaml.encodeToStream import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.Formats +import com.mineinabyss.launchy.logic.Downloader +import com.mineinabyss.launchy.logic.UpdateResult +import com.mineinabyss.launchy.logic.showDialogOnError +import com.mineinabyss.launchy.state.InProgressTask import com.mineinabyss.launchy.state.LaunchyState import com.mineinabyss.launchy.state.modpack.ModpackState -import com.mineinabyss.launchy.ui.screens.Dialog -import com.mineinabyss.launchy.ui.screens.dialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.nio.file.Path import kotlin.io.path.* class GameInstance( val configDir: Path, ) { + val instanceFile = configDir / "instance.yml" + val config: GameInstanceConfig = GameInstanceConfig.read(instanceFile).getOrThrow() + val overridesDir = configDir / "overrides" init { require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } } - 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 updateCheckerScope = CoroutineScope(Dispatchers.IO) + + var updatesAvailable by mutableStateOf(false) + var enabled: Boolean by mutableStateOf(true) + + suspend fun createModpackState(state: LaunchyState): ModpackState? { val userConfig = ModpackUserConfig.load(userConfigFile).getOrNull() ?: ModpackUserConfig() + + state.inProgressTasks["loadingModpack"] = InProgressTask("Loading modpack ${config.name}") val modpack = config.source.loadInstance(this) + .showDialogOnError("Failed to read instance") .getOrElse { - dialog = Dialog.Error( - "Failed read instance", it - .stackTraceToString() - .split("\n").take(5).joinToString("\n") - ) it.printStackTrace() return null } + state.inProgressTasks.remove("loadingModpack") + + val cloudUrl = config.cloudInstanceURL + if (cloudUrl != null) state.ioScope.launch { + val updates = Downloader.checkUpdates(cloudUrl) + if (updates.result != UpdateResult.UpToDate) { + updatesAvailable = true + } + } return ModpackState(this, modpack, userConfig) } 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 7bc68de..6366cd8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt @@ -5,6 +5,7 @@ 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.charleskorn.kaml.encodeToStream import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.Formats import com.mineinabyss.launchy.data.modpacks.source.PackSource @@ -19,6 +20,7 @@ import kotlinx.serialization.Transient import java.nio.file.Path import kotlin.io.path.div import kotlin.io.path.inputStream +import kotlin.io.path.outputStream @Serializable @OptIn(ExperimentalStdlibApi::class) @@ -78,6 +80,10 @@ data class GameInstanceConfig( } } + fun saveTo(path: Path) = runCatching { + Formats.yaml.encodeToStream(this, path.outputStream()) + } + companion object { fun read(path: Path) = runCatching { Formats.yaml.decodeFromStream(serializer(), path.inputStream()) diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt index 54e01c1..ae2d1c4 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt @@ -3,6 +3,8 @@ package com.mineinabyss.launchy.data.modpacks.source import com.mineinabyss.launchy.data.config.GameInstance import com.mineinabyss.launchy.data.modpacks.Modpack import com.mineinabyss.launchy.logic.Downloader +import com.mineinabyss.launchy.logic.UpdateResult +import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -17,6 +19,10 @@ sealed class PackSource { val dependencies = format.getDependencies(instance.minecraftDir) Modpack(dependencies, mods, format.getOverridesPaths(instance.configDir)) } + + override suspend fun updateInstance(instance: GameInstance): Result { + return runCatching { GameInstance(instance.configDir) } + } } @SerialName("downloadFromURL") @@ -24,12 +30,33 @@ sealed class PackSource { class DownloadFromURL(val url: String, val type: PackType) : PackSource() { override suspend fun loadInstance(instance: GameInstance): Result { val downloadTo = type.getFilePath(instance.configDir) - Downloader.download(url, downloadTo, onFinishDownloadWhenChanged = { - type.afterDownload(instance.configDir) - }) + if (!type.isDownloaded(instance.configDir)) + Downloader.download(url, downloadTo, whenChanged = { + type.afterDownload(instance.configDir) + }) + else { + instance.updateCheckerScope.launch { + val updates = Downloader.checkUpdates(url) + if (updates.result != UpdateResult.UpToDate) { + instance.updatesAvailable = true + } + } + } return LocalFile(type).loadInstance(instance) } + + override suspend fun updateInstance(instance: GameInstance): Result { + return runCatching { + val downloadTo = type.getFilePath(instance.configDir) + Downloader.download(url, downloadTo, whenChanged = { + type.afterDownload(instance.configDir) + }) + GameInstance(instance.configDir) + } + } } abstract suspend fun loadInstance(instance: GameInstance): Result + + abstract suspend fun updateInstance(instance: GameInstance): Result } diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt index 62345a8..e4fef0d 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt @@ -18,6 +18,8 @@ import kotlin.io.path.* enum class PackType { Launchy, Modrinth; + fun isDownloaded(configDir: Path) = getFilePath(configDir).isRegularFile() + fun getFilePath(configDir: Path): Path { val ext = when (this) { Launchy -> "yml" diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt index 15933e1..85d5ddb 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt @@ -30,27 +30,41 @@ object Downloader { download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid)) } + class CacheInfo(val result: UpdateResult, val cacheKey: String, val cacheFile: Path) + @OptIn(ExperimentalStdlibApi::class) + suspend fun checkUpdates(url: String): CacheInfo { + val headers = httpClient.head(url).headers + val lastModified = headers["Last-Modified"]?.fromHttpToGmtDate()?.timestamp?.toHexString() + val length = headers["Content-Length"]?.toLongOrNull()?.toHexString() + val cache = "Last-Modified: $lastModified, Content-Length: $length" + val cacheFile = Dirs.cacheDir / "${urlToFileName(url)}.cache" + val result = when { + cacheFile.notExists() -> UpdateResult.NotCached + cacheFile.readText() == cache -> UpdateResult.UpToDate + else -> UpdateResult.HasUpdates + } + return CacheInfo(result, cache, cacheFile) + } + suspend fun download( url: String, writeTo: Path, override: Boolean = true, - onFinishDownloadWhenChanged: () -> Unit = {}, + whenChanged: () -> Unit = {}, onProgressUpdate: (progress: Progress) -> Unit = {}, ): Result { return runCatching { if (!override && writeTo.exists()) return@runCatching val startTime = System.currentTimeMillis() writeTo.createParentDirectories() - val headers = httpClient.head(url).headers - val lastModified = headers["Last-Modified"]?.fromHttpToGmtDate()?.timestamp?.toHexString() - val length = headers["Content-Length"]?.toLongOrNull()?.toHexString() - val cache = "Last-Modified: $lastModified, Content-Length: $length" - val cacheFile = Dirs.cacheDir / "${urlToFileName(url)}.cache" - if (writeTo.exists() && cacheFile.exists() && cacheFile.readText() == cache) return@runCatching - cacheFile.createParentDirectories() - cacheFile.deleteIfExists() - cacheFile.createFile().writeText(cache) + val updates = checkUpdates(url) + if (writeTo.exists() && updates.result == UpdateResult.UpToDate) return@runCatching + updates.cacheFile.apply { + createParentDirectories() + deleteIfExists() + createFile().writeText(updates.cacheKey) + } httpClient.prepareGet(url) { timeout { @@ -76,7 +90,7 @@ object Downloader { writeTo.appendBytes(bytes) } } - onFinishDownloadWhenChanged() + whenChanged() } }.onFailure { it.printStackTrace() diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt index 73c2dfb..80e3a6f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt @@ -1,11 +1,14 @@ package com.mineinabyss.launchy.logic +import com.mineinabyss.launchy.data.Dirs import com.mineinabyss.launchy.data.config.GameInstance +import com.mineinabyss.launchy.data.config.GameInstanceConfig import com.mineinabyss.launchy.state.InProgressTask import com.mineinabyss.launchy.state.LaunchyState +import com.mineinabyss.launchy.ui.screens.Screen +import com.mineinabyss.launchy.ui.screens.screen import kotlinx.coroutines.launch -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively +import kotlin.io.path.* object Instances { @OptIn(ExperimentalPathApi::class) @@ -21,4 +24,49 @@ object Instances { state.inProgressTasks.remove("deleteInstance") } } + + fun GameInstance.updateInstance( + state: LaunchyState, + onSuccess: () -> Unit = {}, + ) { + state.inProgressTasks["updateInstance"] = InProgressTask("Updating instance: ${config.name}") + try { + screen = Screen.Default + enabled = false + val index = state.gameInstances.indexOf(this) + state.ioScope.launch { + val cloudUrl = config.cloudInstanceURL + if (cloudUrl != null) { + val newCloudInstance = Dirs.tmpCloudInstance(cloudUrl) + Downloader.download(cloudUrl, newCloudInstance, whenChanged = { + instanceFile.copyTo(configDir / "instance-backup.yml", overwrite = true) + GameInstanceConfig.read(newCloudInstance).onSuccess { cloudConfig -> + instanceFile.deleteIfExists() + instanceFile.createFile() + config.copy( + description = cloudConfig.description, + backgroundURL = cloudConfig.backgroundURL, + logoURL = cloudConfig.logoURL, + hue = cloudConfig.hue, + source = cloudConfig.source, + ).saveTo(instanceFile) + } + }) + } + + // Handle case where we just updated from cloud + val config = GameInstanceConfig.read(instanceFile).getOrElse { config } + + config.source.updateInstance(this@updateInstance) + .showDialogOnError("Failed to update instance ${config.name}") + .onFailure { it.printStackTrace() } + .onSuccess { + state.gameInstances[index] = it + onSuccess() + } + } + } finally { + state.inProgressTasks.remove("updateInstance") + } + } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt new file mode 100644 index 0000000..2f73e69 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt @@ -0,0 +1,7 @@ +package com.mineinabyss.launchy.logic + +sealed interface UpdateResult { + object UpToDate : UpdateResult + object HasUpdates : UpdateResult + object NotCached : UpdateResult +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt index e231525..92df2df 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt @@ -3,8 +3,8 @@ package com.mineinabyss.launchy.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* -import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close @@ -20,9 +20,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.ui.elements.BetterWindowDraggableArea import com.mineinabyss.launchy.ui.state.TopBarState @Composable @@ -44,7 +46,13 @@ fun AppTopBar( showTitle: Boolean, showBackButton: Boolean, onBackButtonClicked: (() -> Unit), -) = state.windowScope.WindowDraggableArea { +) = state.windowScope.BetterWindowDraggableArea( + Modifier.pointerInput(Unit) { + detectTapGestures(onDoubleTap = { + state.toggleMaximized() + }) + } +) { Box( Modifier.fillMaxWidth().height(40.dp) ) { 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 6f33a9f..e6aecff 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt @@ -10,19 +10,21 @@ 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 androidx.compose.ui.unit.dp import com.mineinabyss.launchy.ui.screens.screen @Composable fun ComfyWidth( - content: @Composable () -> Unit + overrideWidth: Dp? = null, + content: @Composable () -> Unit, ) { val endDp = if (screen.showSidebar) 16.dp else 0.dp Box( Modifier.fillMaxWidth().padding(end = endDp), contentAlignment = androidx.compose.ui.Alignment.Center ) { - Box(Modifier.width(800.dp)) { + Box(Modifier.width(overrideWidth ?: 800.dp)) { content() } } @@ -40,15 +42,16 @@ fun ComfyTitle( @Composable fun ComfyContent( modifier: Modifier = Modifier, + overrideWidth: Dp? = null, content: @Composable () -> Unit, ) { - ComfyWidth { + ComfyWidth(overrideWidth) { Surface( tonalElevation = 1.dp, shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).then(modifier) ) { - Box(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp)) { + Box(Modifier.padding(20.dp)) { content() } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt new file mode 100644 index 0000000..54e0b4f --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt @@ -0,0 +1,25 @@ +package com.mineinabyss.launchy.ui.elements + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowScope +import com.mineinabyss.launchy.ui.state.TopBarProvider + +@Composable +fun WindowScope.BetterWindowDraggableArea( + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} +) { + val topBar = TopBarProvider.current + WindowDraggableArea(modifier.pointerInput(Unit) { + detectDragGestures(onDragStart = { + topBar.windowState.placement = WindowPlacement.Floating + }) { _, _ -> } + }) { + content() + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt index 7337977..9627664 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt @@ -26,7 +26,12 @@ sealed interface Dialog { companion object { fun fromException(exception: Throwable, title: String? = null): Error { - return Error(title ?: "Error", exception.message ?: "An error occurred") + return Error( + title ?: "Error", + exception + .stackTraceToString() + .split("\n").joinToString("\n", limit = 5) + ) } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt index 3cddeb1..60b3aee 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.LayoutCoordinates @@ -32,7 +33,7 @@ fun LeftSidebar() { var showAccountsPopup by remember { mutableStateOf(false) } var accountHeadPosition: LayoutCoordinates? by remember { mutableStateOf(null) } - NavigationRail { + NavigationRail(containerColor = Color.Transparent) { Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt index bb137e3..9824dc6 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt @@ -1,7 +1,7 @@ package com.mineinabyss.launchy.ui.screens sealed class Screen( - val transparentTopBar: Boolean = false, + val transparentTopBar: Boolean = true, val showTitle: Boolean = false, val showSidebar: Boolean = false, ) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt index bbe03cf..527bf21 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,7 +17,7 @@ import com.mineinabyss.launchy.LocalLaunchyState fun HomeScreen() { val state = LocalLaunchyState - Scaffold { paddingValues -> + Box { val scrollState = rememberLazyListState() BoxWithConstraints { Column(Modifier.padding(end = 20.dp).fillMaxSize()) { 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 07eba9f..f838870 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 @@ -15,11 +15,14 @@ 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.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.data.config.GameInstance import com.mineinabyss.launchy.data.config.GameInstanceConfig +import com.mineinabyss.launchy.state.InProgressTask import com.mineinabyss.launchy.ui.colors.LaunchyColors import com.mineinabyss.launchy.ui.colors.currentHue import com.mineinabyss.launchy.ui.elements.Tooltip @@ -53,20 +56,25 @@ fun InstanceCard( onClick = { instance ?: return@Card coroutineScope.launch { - state.modpackState = instance.createModpackState() + state.modpackState = instance.createModpackState(state) currentHue = instance.config.hue screen = Screen.Instance } }, + enabled = instance?.enabled == true, modifier = modifier.height(cardHeight).width(cardWidth), ) { Box(Modifier.fillMaxSize()) { androidx.compose.animation.AnimatedVisibility( visible = background != null, enter = fadeIn(), - modifier = Modifier.fillMaxSize()) { + modifier = Modifier.fillMaxSize() + ) { if (background != null) Image( painter = background!!, + colorFilter = + if (instance?.enabled == false) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + else null, contentDescription = "Pack background image", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() @@ -80,6 +88,7 @@ fun InstanceCard( ) { Icon(Icons.Rounded.Cloud, "Cloud modpack") } + Row( Modifier.align(Alignment.BottomStart).padding(cardPadding), verticalAlignment = Alignment.Bottom, @@ -90,8 +99,15 @@ fun InstanceCard( Text(config.description, style = MaterialTheme.typography.bodyMedium) } Spacer(Modifier.weight(1f)) - if (instance != null) - PlayButton(hideText = true, instance) { instance.createModpackState() } + if (instance?.enabled == true) + PlayButton(hideText = true, instance) { + state.inProgressTasks["modpackState"] = InProgressTask("Reading modpack configuration") + try { + instance.createModpackState(state) + } finally { + state.inProgressTasks.remove("modpackState") + } + } } } } 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 3667e5a..f6f9a23 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,6 +1,11 @@ package com.mineinabyss.launchy.ui.screens.home.newinstance -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Link @@ -15,7 +20,6 @@ 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.logic.urlToFileName import com.mineinabyss.launchy.state.InProgressTask import com.mineinabyss.launchy.ui.elements.AnimatedTab import com.mineinabyss.launchy.ui.elements.ComfyContent @@ -28,7 +32,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.collections.set import kotlin.io.path.deleteIfExists -import kotlin.io.path.div import kotlin.io.path.exists val validInstanceNameRegex = Regex("^[a-zA-Z0-9_ ]+$") @@ -46,153 +49,164 @@ fun NewInstance() { selected = true, onClick = { selectedTabIndex = 0 } ) -// Tab( -// text = { Text("Manual") }, -// selected = false, -// onClick = { selectedTabIndex = 1 } -// ) } } val coroutineScope = rememberCoroutineScope() Box { - AnimatedTab(visible = selectedTabIndex == 0 && importingInstance == null) { - Column { - ComfyTitle("Import from link") + ImportTab(selectedTabIndex == 0 && importingInstance == null, onGetInstance = { + importingInstance = it + }) + ConfirmImportTab(selectedTabIndex == 0 && importingInstance != null, importingInstance) + } + } +} - ComfyContent { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - var urlText by remember { mutableStateOf("") } - var urlValid by remember { mutableStateOf(true) } - fun urlValid() = urlText.startsWith("https://") || urlText.startsWith("http://") - var urlFailedToParse by remember { mutableStateOf(false) } +@Composable +fun ImportTab(visible: Boolean, onGetInstance: (GameInstanceConfig) -> Unit = {}) { + val state = LocalLaunchyState + AnimatedTab(visible) { + Column { + ComfyTitle("Import from link") - OutlinedTextField( - value = urlText, - singleLine = true, - isError = !urlValid || urlFailedToParse, - leadingIcon = { Icon(Icons.Rounded.Link, contentDescription = "Link") }, - onValueChange = { - urlText = it - urlFailedToParse = false - }, - label = { Text("Link") }, - supportingText = { - if (!urlValid) Text("Must be valid URL") - else if (urlFailedToParse) Text("URL is not a valid instance file") - }, - modifier = Modifier.fillMaxWidth() - ) + ComfyContent { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + var urlText by remember { mutableStateOf("") } + var urlValid by remember { mutableStateOf(true) } + fun urlValid() = urlText.startsWith("https://") || urlText.startsWith("http://") + var urlFailedToParse by remember { mutableStateOf(false) } - TextButton(onClick = { - urlValid = urlValid() - if (!urlValid) return@TextButton - val taskKey = "import-cloud-instance-${urlToFileName(urlText)}" - val downloadPath = Dirs.tmp / "launchy-cloud-instance-${urlToFileName(urlText)}.yml" - downloadPath.deleteIfExists() - coroutineScope.launch(Dispatchers.IO) { - 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) - return@launch - } - importingInstance = cloudInstance.copy( - cloudInstanceURL = urlText - ) - state.inProgressTasks.remove(taskKey) - } - }) { - Text("Import", color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = urlText, + singleLine = true, + isError = !urlValid || urlFailedToParse, + leadingIcon = { Icon(Icons.Rounded.Link, contentDescription = "Link") }, + onValueChange = { + urlText = it + urlFailedToParse = false + }, + label = { Text("Link") }, + supportingText = { + if (!urlValid) Text("Must be valid URL") + else if (urlFailedToParse) Text("URL is not a valid instance file") + }, + modifier = Modifier.fillMaxWidth() + ) + + TextButton(onClick = { + urlValid = urlValid() + if (!urlValid) return@TextButton + val taskKey = "importCloudInstance" + val downloadPath = Dirs.tmpCloudInstance(urlText) + downloadPath.deleteIfExists() + state.ioScope.launch(Dispatchers.IO) { + 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) + return@launch } + state.inProgressTasks.remove(taskKey) + onGetInstance( + cloudInstance.copy( + cloudInstanceURL = urlText + ) + ) } + }) { + Text("Import", color = MaterialTheme.colorScheme.primary) } } } - AnimatedTab(importingInstance != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ComfyWidth { - Text("Confirm import", style = MaterialTheme.typography.headlineMedium) - } - ComfyContent { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - var nameText by remember { mutableStateOf(importingInstance?.name ?: "") } - fun nameValid() = nameText.matches(validInstanceNameRegex) - fun packWithNameExists() = Dirs.modpackConfigDir(nameText).exists() - var nameValid by remember { mutableStateOf(nameValid()) } - var packWithNameExists by remember { mutableStateOf(packWithNameExists()) } +// PopularInstances() + } + } - OutlinedTextField( - value = nameText, - singleLine = true, - isError = !nameValid || packWithNameExists, - leadingIcon = { Icon(Icons.Rounded.TextFields, contentDescription = "Name") }, - supportingText = { - if (!nameValid) Text("Name must be alphanumeric") - else if (packWithNameExists) Text("A modpack with this name already exists") - }, - onValueChange = { - nameText = it - packWithNameExists = false - }, - label = { Text("Instance name") }, - modifier = Modifier.fillMaxWidth() - ) +} - TextButton( - enabled = importingInstance != null, - onClick = { - nameValid = nameValid() - packWithNameExists = packWithNameExists() - val instance = importingInstance ?: return@TextButton - if (!nameValid || packWithNameExists) return@TextButton - GameInstance.create( - state, instance.copy( - name = nameText, - ) - ) - screen = Screen.Default - } - ) { - Text("Confirm", color = MaterialTheme.colorScheme.primary) - } - } - } +@Composable +fun ConfirmImportTab(visible: Boolean, importingInstance: GameInstanceConfig?) { + val state = LocalLaunchyState + AnimatedTab(visible) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ComfyWidth { + Text("Confirm import", style = MaterialTheme.typography.headlineMedium) + } + ComfyContent { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + var nameText by remember { mutableStateOf(importingInstance?.name ?: "") } + fun nameValid() = nameText.matches(validInstanceNameRegex) + fun instanceExists() = Dirs.modpackConfigDir(nameText).exists() + var nameValid by remember { mutableStateOf(nameValid()) } + var instanceExists by remember { mutableStateOf(instanceExists()) } - ComfyWidth { - importingInstance?.let { - InstanceCard( - it.copy(name = "Preview"), - modifier = Modifier.fillMaxWidth() + OutlinedTextField( + value = nameText, + singleLine = true, + isError = !nameValid || instanceExists, + leadingIcon = { Icon(Icons.Rounded.TextFields, contentDescription = "Name") }, + supportingText = { + if (!nameValid) Text("Name must be alphanumeric") + else if (instanceExists) Text("An instance with this name already exists") + }, + onValueChange = { + nameText = it + instanceExists = false + }, + label = { Text("Instance name") }, + modifier = Modifier.fillMaxWidth() + ) + + TextButton( + enabled = importingInstance != null, + onClick = { + nameValid = nameValid() + instanceExists = instanceExists() + val instance = importingInstance ?: return@TextButton + if (!nameValid || instanceExists) return@TextButton + GameInstance.create( + state, instance.copy( + name = nameText, + ) ) + screen = Screen.Default } + ) { + Text("Confirm", color = MaterialTheme.colorScheme.primary) } } } + + ComfyWidth { + importingInstance?.let { + InstanceCard( + it.copy(name = "Preview"), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +@Composable +fun PopularInstances() { + val state = LocalLaunchyState + val coroutineScope = rememberCoroutineScope() + val popularInstances = remember { + listOf( + "" + ) + } + ComfyTitle("Popular instances") + ComfyContent { + LazyRow { + items(popularInstances) { +// InstanceCard(it, modifier = Modifier.padding(8.dp)) + } } -// AnimatedVisibility(visible = selectedTabIndex == 1, enter = fadeIn(), exit = fadeOut()) { -// Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { -// Text("Create your own instance", style = MaterialTheme.typography.headlineMedium) -//// OutlinedTextField( -//// value = urlText, -//// singleLine = true, -//// onValueChange = { urlText = it }, -//// label = { Text("Name") }, -//// leadingIcon = { Icon(Icons.Rounded.TextFields, contentDescription = "Name") }, -//// modifier = Modifier.fillMaxWidth() -//// ) -//// OutlinedTextField( -//// value = urlText, -//// singleLine = true, -//// onValueChange = { urlText = it }, -//// label = { Text("Minecraft version") }, -//// leadingIcon = { Icon(Icons.Rounded.Numbers, contentDescription = "Minecraft") }, -//// 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 e6e61da..906d35b 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 @@ -39,7 +39,7 @@ fun SettingsScreen() { ComfyContent { var directoryPickerShown by remember { mutableStateOf(false) } Column( - Modifier.padding(16.dp).verticalScroll(scrollState), + Modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Column { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ModpackScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ModpackScreen.kt index 61589ad..53c319b 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ModpackScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ModpackScreen.kt @@ -1,5 +1,6 @@ package com.mineinabyss.launchy.ui.screens.modpack.main +import androidx.compose.animation.AnimatedVisibility import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable @@ -11,6 +12,7 @@ import com.mineinabyss.launchy.LocalLaunchyState import com.mineinabyss.launchy.ui.screens.LocalModpackState import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.PlayButton import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.SettingsButton +import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.UpdateButton import com.mineinabyss.launchy.ui.state.windowScope @ExperimentalComposeUiApi @@ -38,6 +40,9 @@ fun ModpackScreen() { modifier = Modifier.fillMaxWidth().weight(1f, false), ) { PlayButton(hideText = false, packState.instance) { packState } + AnimatedVisibility(packState.instance.updatesAvailable) { + UpdateButton() + } SettingsButton() } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt index 17e69c7..439d6ef 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt @@ -1,13 +1,12 @@ package com.mineinabyss.launchy.ui.screens.modpack.main.buttons import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Feed -import androidx.compose.material.icons.rounded.Feed -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -25,10 +24,15 @@ fun NewsButton(hasUpdates: Boolean) { Icon(Icons.AutoMirrored.Rounded.Feed, contentDescription = "Settings") Text("News") } - if (hasUpdates) Surface( - Modifier.size(12.dp).align(Alignment.TopEnd).offset((-2).dp, (2).dp), - shape = CircleShape, - color = Color(255, 138, 128) - ) {} + if (hasUpdates) Ping() } } + +@Composable +fun BoxScope.Ping() { + Surface( + Modifier.size(12.dp).align(Alignment.TopEnd).offset((-2).dp, (2).dp), + shape = CircleShape, + color = Color(255, 138, 128) + ) {} +} 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 33f2848..fb5a409 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 @@ -70,11 +70,12 @@ fun PlayButton( Launcher.launch(state, packState, state.profile) } } + packState.queued.areOperationsQueued -> { dialog = Dialog.Options( - title = "Update before launch?", - message = "Updates are available for this modpack. Would you like to download them?", - acceptText = "Download", + title = "Install changes before launch?", + message = "New mods have been selected/removed,\nwould you like to apply these changes?", + acceptText = "Install", declineText = "Skip", onAccept = { state.ioScope.launch { @@ -84,12 +85,13 @@ fun PlayButton( }, onDecline = { state.ioScope.launch { - packState.install(state).join() + packState.ensureCurrentDepsInstalled(state) Launcher.launch(state, packState, state.profile) } } ) } + else -> { coroutineScope.launch(Dispatchers.IO) { packState.ensureCurrentDepsInstalled(state) @@ -104,7 +106,10 @@ fun PlayButton( } } } - val enabled = state.profile.currentProfile != null && foundPackState?.downloads?.isDownloading != true + val enabled = state.profile.currentProfile != null + && foundPackState?.downloads?.isDownloading != true + && state.inProgressTasks.isEmpty() + if (hideText) Button( enabled = enabled, onClick = onClick, diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt new file mode 100644 index 0000000..fe605bc --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt @@ -0,0 +1,26 @@ +package com.mineinabyss.launchy.ui.screens.modpack.main.buttons + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Update +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.mineinabyss.launchy.LocalLaunchyState +import com.mineinabyss.launchy.logic.Instances.updateInstance +import com.mineinabyss.launchy.ui.screens.LocalModpackState + +@Composable +fun UpdateButton() { + val state = LocalLaunchyState + val pack = LocalModpackState + Box { + Button(onClick = { + pack.instance.updateInstance(state) + }) { + Icon(Icons.Rounded.Update, contentDescription = "Update") + Text("Update Available") + } + } +} 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 21c5fa2..ca01eb2 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 @@ -30,14 +30,14 @@ object InfoBarProperties { val height = 64.dp } @Composable -fun InfoBar() { +fun InfoBar(modifier: Modifier = Modifier) { val state = LocalLaunchyState val packState = LocalModpackState Surface( tonalElevation = 2.dp, shadowElevation = 0.dp, shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), - modifier = Modifier.fillMaxWidth().height(InfoBarProperties.height), + modifier = Modifier.fillMaxWidth().height(InfoBarProperties.height).then(modifier), ) { Row( verticalAlignment = Alignment.CenterVertically, 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 b31e205..2b1ebff 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,15 +7,16 @@ 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.material.TextButton import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color 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.logic.Instances.updateInstance import com.mineinabyss.launchy.ui.elements.AnimatedTab import com.mineinabyss.launchy.ui.elements.ComfyContent import com.mineinabyss.launchy.ui.elements.ComfyWidth @@ -31,7 +32,7 @@ fun InstanceSettingsScreen() { var selectedTabIndex by remember { mutableStateOf(0) } ComfyWidth { Column { - PrimaryTabRow(selectedTabIndex = selectedTabIndex) { + PrimaryTabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent) { Tab( text = { Text("Manage Mods") }, selected = selectedTabIndex == 0, @@ -61,20 +62,31 @@ fun OptionsTab() { val pack = LocalModpackState ComfyContent(Modifier.padding(16.dp)) { - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TitleSmall("Mods") + OutlinedButton(onClick = { pack.instance.updateInstance(state) }) { + Text("Force update Instance") + } + TitleSmall("Danger zone") - Row { - TextButton(onClick = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { screen = Screen.Default pack.instance.delete(state, deleteDotMinecraft = false) }) { - Text("Delete Instance from config", color = MaterialTheme.colorScheme.primary) + Text("Delete Instance from config") } - TextButton(onClick = { - screen = Screen.Default - pack.instance.delete(state, deleteDotMinecraft = true) - }) { - Text("Delete Instance and its .minecraft", color = MaterialTheme.colorScheme.error) + OutlinedButton( + onClick = { + screen = Screen.Default + pack.instance.delete(state, deleteDotMinecraft = true) + }, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + ) { + Text("Delete Instance and its .minecraft") } } } @@ -85,6 +97,7 @@ fun OptionsTab() { fun ModManagement() { val state = LocalModpackState Scaffold( + containerColor = Color.Transparent, bottomBar = { InfoBar() }, ) { paddingValues -> Box(Modifier.padding(paddingValues)) {