Refactor UI components and add status modal for addon installation

This commit is contained in:
tapframe 2026-03-04 09:05:00 +05:30
parent 4b99274756
commit c8efcdc66b
8 changed files with 225 additions and 48 deletions

View file

@ -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

View file

@ -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)
}
}
}
}
}
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.unit.Dp
internal expect val nuvioPlatformExtraTopPadding: Dp

View file

@ -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,
),

View file

@ -69,6 +69,6 @@ internal fun List<ManagedAddon>.toOverview(): AddonOverview =
)
sealed interface AddAddonResult {
data object Success : AddAddonResult
data class Success(val manifest: AddonManifest) : AddAddonResult
data class Error(val message: String) : AddAddonResult
}

View file

@ -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) {

View file

@ -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<String?>(null) }
var sortAscending by rememberSaveable { mutableStateOf(true) }
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(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 {

View file

@ -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