diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt index 6bb064946..b56b8c730 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt @@ -27,6 +27,7 @@ internal object AddonManifestParser { name = root.requiredString("name"), description = root.requiredString("description"), version = root.requiredString("version"), + logoUrl = root.optionalString("logo")?.resolveAgainstManifest(manifestUrl), resources = root.resources(defaultTypes, defaultPrefixes), types = defaultTypes, idPrefixes = defaultPrefixes, @@ -110,3 +111,25 @@ internal object AddonManifestParser { private fun JsonObject.int(name: String): Int? = this[name]?.jsonPrimitive?.contentOrNull?.toIntOrNull() } + +private fun String.resolveAgainstManifest(manifestUrl: String): String = + when { + startsWith("http://") || startsWith("https://") || startsWith("data:") -> this + startsWith("//") -> "https:$this" + else -> { + val manifestBase = manifestUrl.substringBefore("?").substringBeforeLast('/', "") + if (startsWith('/')) { + val origin = manifestBase.substringBefore("/", missingDelimiterValue = manifestBase) + val schemeAndHost = if (origin.contains("://")) { + val scheme = origin.substringBefore("://") + val host = manifestBase.substringAfter("://").substringBefore("/") + "$scheme://$host" + } else { + manifestBase + } + "$schemeAndHost$this" + } else { + "$manifestBase/$this" + } + } + } 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 5ac995149..1d0741095 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 @@ -5,6 +5,7 @@ data class AddonManifest( val name: String, val description: String, val version: String, + val logoUrl: String? = null, val resources: List, val types: List, val idPrefixes: List = emptyList(), 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 836fc85ab..77448ac7c 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 @@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -35,9 +34,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioIconActionButton import com.nuvio.app.core.ui.NuvioInfoBadge import com.nuvio.app.core.ui.NuvioInputField @@ -81,15 +82,10 @@ internal fun AddonsSettingsPageContent( 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) { - uiState.addons.sortedBy { it.displayTitle.lowercase() } - } else { - uiState.addons.sortedByDescending { it.displayTitle.lowercase() } - } + val sortedAddons = remember(uiState.addons) { + uiState.addons.sortedBy { it.displayTitle.lowercase() } } val overview = remember(sortedAddons) { sortedAddons.toOverview() } @@ -98,22 +94,6 @@ internal fun AddonsSettingsPageContent( verticalArrangement = Arrangement.spacedBy(12.dp), ) { SectionHeader("OVERVIEW") - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - NuvioIconActionButton( - icon = Icons.Rounded.SwapVert, - contentDescription = if (sortAscending) "Sort descending" else "Sort ascending", - onClick = { sortAscending = !sortAscending }, - ) - NuvioIconActionButton( - icon = Icons.Rounded.Refresh, - contentDescription = "Refresh all addons", - onClick = { AddonRepository.refreshAll() }, - ) - } - OverviewCard(overview = overview) SectionHeader("ADD ADDON") @@ -345,6 +325,7 @@ private fun InstalledAddonCard( verticalAlignment = Alignment.Top, ) { AddonIconBadge( + imageUrl = manifest?.logoUrl, icon = Icons.Rounded.Extension, tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -357,12 +338,6 @@ private fun InstalledAddonCard( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = addon.manifestUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) manifest?.version?.let { version -> Spacer(modifier = Modifier.height(8.dp)) Text( @@ -462,6 +437,7 @@ private fun InstalledAddonCard( @Composable private fun AddonIconBadge( + imageUrl: String?, icon: ImageVector, tint: Color, ) { @@ -472,12 +448,21 @@ private fun AddonIconBadge( .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(34.dp), - ) + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(34.dp), + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 1ebb8cfe2..08e16ba71 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -1,10 +1,17 @@ package com.nuvio.app.features.settings +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,42 +58,19 @@ internal fun LazyListScope.homescreenSettingsContent( } } item { - if (items.isNotEmpty()) { + if (heroEnabled && items.isNotEmpty()) { + var heroSourcesExpanded by remember { mutableStateOf(false) } SettingsSection( title = "HERO SOURCES", isTablet = isTablet, ) { - SettingsGroup(isTablet = isTablet) { - Text( - text = "$selectedHeroSourceCount of ${HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT} selected", - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (items.isNotEmpty()) { - SettingsGroupDivider(isTablet = isTablet) - } - items.forEachIndexed { index, item -> - if (index > 0) { - SettingsGroupDivider(isTablet = isTablet) - } - SettingsSwitchRow( - title = item.displayTitle, - description = if (!item.heroSourceEnabled && - selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT - ) { - "${item.addonName} • Limit reached (max 2)" - } else { - item.addonName - }, - checked = item.heroSourceEnabled, - enabled = item.heroSourceEnabled || - selectedHeroSourceCount < HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, - isTablet = isTablet, - onCheckedChange = { HomeCatalogSettingsRepository.setHeroSourceEnabled(item.key, it) }, - ) - } - } + HeroSourcesDropdown( + isTablet = isTablet, + items = items, + selectedHeroSourceCount = selectedHeroSourceCount, + expanded = heroSourcesExpanded, + onExpandedChange = { heroSourcesExpanded = it }, + ) } } } @@ -111,6 +95,75 @@ internal fun LazyListScope.homescreenSettingsContent( } } +@Composable +private fun HeroSourcesDropdown( + isTablet: Boolean, + items: List, + selectedHeroSourceCount: Int, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, +) { + SettingsGroup(isTablet = isTablet) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp) + .clickable { onExpandedChange(!expanded) }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "$selectedHeroSourceCount of ${HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT} selected", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = items.filter { it.heroSourceEnabled } + .joinToString(separator = ", ") { it.displayTitle } + .ifBlank { "No hero sources selected" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = if (expanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded) { + Column { + SettingsGroupDivider(isTablet = isTablet) + items.forEachIndexed { index, item -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + SettingsSwitchRow( + title = item.displayTitle, + description = if (!item.heroSourceEnabled && + selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT + ) { + "${item.addonName} • Limit reached (max 2)" + } else { + item.addonName + }, + checked = item.heroSourceEnabled, + enabled = item.heroSourceEnabled || + selectedHeroSourceCount < HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, + isTablet = isTablet, + onCheckedChange = { HomeCatalogSettingsRepository.setHeroSourceEnabled(item.key, it) }, + ) + } + } + } + } +} + @Composable private fun HomescreenSummaryCard( isTablet: Boolean,