diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.android.kt new file mode 100644 index 000000000..6dfac6840 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal actual val nuvioPlatformExtraTopPadding: Dp = 12.dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index f7079ed27..74644ab7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -24,8 +24,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -53,12 +56,12 @@ fun NuvioScreen( .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.safeDrawing), contentPadding = PaddingValues( - start = 22.dp, - top = 14.dp, - end = 22.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 28.dp, + start = 18.dp, + top = 12.dp + nuvioPlatformExtraTopPadding, + end = 18.dp, + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 22.dp, ), - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), content = content, ) } @@ -72,12 +75,12 @@ fun NuvioSurfaceCard( Surface( modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(24.dp), tonalElevation = tonalElevation.dp, shadowElevation = 0.dp, ) { Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 22.dp), + modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp), content = content, ) } @@ -100,7 +103,7 @@ fun NuvioScreenHeader( color = MaterialTheme.colorScheme.onBackground, ) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, content = actions, ) @@ -176,9 +179,9 @@ fun NuvioPrimaryButton( onClick = onClick, modifier = modifier .fillMaxWidth() - .height(56.dp), + .height(52.dp), enabled = enabled, - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, @@ -206,7 +209,7 @@ fun NuvioInputField( onValueChange = onValueChange, modifier = modifier.fillMaxWidth(), singleLine = true, - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(14.dp), placeholder = { Text( text = placeholder, @@ -236,7 +239,7 @@ fun NuvioInfoBadge( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(999.dp), ) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), ) { Text( text = text, @@ -269,3 +272,82 @@ fun NuvioInlineMetadata( ) } } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun NuvioStatusModal( + title: String, + message: String, + modifier: Modifier = Modifier, + isVisible: Boolean, + isBusy: Boolean = false, + confirmText: String = "OK", + dismissText: String? = null, + onConfirm: () -> Unit, + onDismiss: (() -> Unit)? = null, +) { + if (!isVisible) return + + BasicAlertDialog( + onDismissRequest = { + if (!isBusy) { + onDismiss?.invoke() ?: onConfirm() + } + }, + ) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(24.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + ) { + if (isBusy) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.5.dp, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (!isBusy && dismissText != null && onDismiss != null) { + Button( + onClick = onDismiss, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text(dismissText) + } + Spacer(modifier = Modifier.width(10.dp)) + } + Button( + onClick = onConfirm, + enabled = !isBusy, + shape = RoundedCornerShape(16.dp), + ) { + Text(confirmText) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt new file mode 100644 index 000000000..80c924e50 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.unit.Dp + +internal expect val nuvioPlatformExtraTopPadding: Dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index 02ee9b098..64f32551e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -33,45 +33,45 @@ private val NuvioDarkColors = darkColorScheme( private val NuvioTypography = Typography( displayLarge = TextStyle( - fontSize = 42.sp, - lineHeight = 46.sp, + fontSize = 38.sp, + lineHeight = 42.sp, fontWeight = FontWeight.Black, letterSpacing = (-1.2).sp, ), headlineLarge = TextStyle( - fontSize = 30.sp, - lineHeight = 34.sp, + fontSize = 26.sp, + lineHeight = 30.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.8).sp, ), titleLarge = TextStyle( - fontSize = 20.sp, - lineHeight = 26.sp, + fontSize = 18.sp, + lineHeight = 24.sp, fontWeight = FontWeight.Bold, ), titleMedium = TextStyle( - fontSize = 17.sp, - lineHeight = 22.sp, - fontWeight = FontWeight.SemiBold, - ), - bodyLarge = TextStyle( fontSize = 16.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.Medium, - ), - bodyMedium = TextStyle( - fontSize = 15.sp, - lineHeight = 22.sp, - fontWeight = FontWeight.Normal, - ), - labelLarge = TextStyle( - fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, ), + bodyLarge = TextStyle( + fontSize = 15.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + ), + bodyMedium = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + ), + labelLarge = TextStyle( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.SemiBold, + ), labelMedium = TextStyle( - fontSize = 13.sp, - lineHeight = 16.sp, + fontSize = 12.sp, + lineHeight = 14.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index d321f2ee5..758a2883e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -69,6 +69,6 @@ internal fun List.toOverview(): AddonOverview = ) sealed interface AddAddonResult { - data object Success : AddAddonResult + data class Success(val manifest: AddonManifest) : AddAddonResult data class Error(val message: String) : AddAddonResult } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index 579a2a0d3..a0e82ee85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext object AddonRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -35,7 +36,7 @@ object AddonRepository { storedUrls.forEach(::refreshAddon) } - fun addAddon(rawUrl: String): AddAddonResult { + suspend fun addAddon(rawUrl: String): AddAddonResult { val manifestUrl = try { normalizeManifestUrl(rawUrl) } catch (error: IllegalArgumentException) { @@ -46,19 +47,32 @@ object AddonRepository { return AddAddonResult.Error("That addon is already installed.") } + val manifest = try { + withContext(Dispatchers.Default) { + val payload = httpGetText(manifestUrl) + AddonManifestParser.parse( + manifestUrl = manifestUrl, + payload = payload, + ) + } + } catch (error: Throwable) { + return AddAddonResult.Error(error.message ?: "Unable to load manifest") + } + _uiState.update { current -> current.copy( addons = listOf( ManagedAddon( manifestUrl = manifestUrl, - isRefreshing = true, + manifest = manifest, + isRefreshing = false, + errorMessage = null, ), ) + current.addons, ) } persist() - refreshAddon(manifestUrl) - return AddAddonResult.Success + return AddAddonResult.Success(manifest) } fun removeAddon(manifestUrl: String) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index fb079aaa4..038f172f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -44,7 +45,9 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSectionLabel +import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import kotlinx.coroutines.launch @Composable fun AddonsScreen( @@ -55,9 +58,11 @@ fun AddonsScreen( } val uiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() var addonUrl by rememberSaveable { mutableStateOf("") } var formMessage by rememberSaveable { mutableStateOf(null) } var sortAscending by rememberSaveable { mutableStateOf(true) } + var installModalState by remember { mutableStateOf(null) } val sortedAddons = remember(uiState.addons, sortAscending) { if (sortAscending) { @@ -99,14 +104,25 @@ fun AddonsScreen( formMessage = null }, onAddClick = { - when (val result = AddonRepository.addAddon(addonUrl)) { - AddAddonResult.Success -> { - addonUrl = "" - formMessage = null - } + val requestedUrl = addonUrl.trim() + if (requestedUrl.isBlank()) { + formMessage = "Enter an addon URL." + return@AddAddonCard + } - is AddAddonResult.Error -> { - formMessage = result.message + formMessage = null + installModalState = AddonInstallModalState.Checking + coroutineScope.launch { + val result = AddonRepository.addAddon(requestedUrl) + installModalState = when (result) { + is AddAddonResult.Success -> { + addonUrl = "" + AddonInstallModalState.Success(result.manifest.name) + } + + is AddAddonResult.Error -> { + AddonInstallModalState.Error(result.message) + } } } }, @@ -132,6 +148,22 @@ fun AddonsScreen( } } } + + val modalState = installModalState + if (modalState != null) { + NuvioStatusModal( + title = modalState.title, + message = modalState.message, + isVisible = true, + isBusy = modalState.isBusy, + confirmText = modalState.confirmText, + onConfirm = { + if (!modalState.isBusy) { + installModalState = null + } + }, + ) + } } @Composable @@ -216,7 +248,7 @@ private fun AddAddonCard( ) Spacer(modifier = Modifier.height(18.dp)) NuvioPrimaryButton( - text = "Add Addon", + text = "Install Addon", enabled = addonUrl.isNotBlank(), onClick = onAddClick, ) @@ -231,6 +263,38 @@ private fun AddAddonCard( } } +private sealed interface AddonInstallModalState { + val title: String + val message: String + val confirmText: String + val isBusy: Boolean + + data object Checking : AddonInstallModalState { + override val title: String = "Checking Addon" + override val message: String = "Validating the manifest URL and loading addon details before install." + override val confirmText: String = "Installing" + override val isBusy: Boolean = true + } + + data class Success( + private val addonName: String, + ) : AddonInstallModalState { + override val title: String = "Addon Installed" + override val message: String = "$addonName was validated and added successfully." + override val confirmText: String = "Done" + override val isBusy: Boolean = false + } + + data class Error( + private val reason: String, + ) : AddonInstallModalState { + override val title: String = "Install Failed" + override val message: String = reason + override val confirmText: String = "Close" + override val isBusy: Boolean = false + } +} + @Composable private fun EmptyStateCard() { NuvioSurfaceCard { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.ios.kt new file mode 100644 index 000000000..f9886e4bd --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.ios.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp