diff --git a/.gitignore b/.gitignore index adfa9bffc..8e1c8856a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ +Docs \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f3b5071d1..ed51abf18 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -29,8 +29,11 @@ kotlin { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + implementation(libs.ktor.client.android) } commonMain.dependencies { + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor3) implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) @@ -42,6 +45,9 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.serialization.json) } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } commonTest.dependencies { implementation(libs.kotlin.test) } 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 index 6dfac6840..f9886e4bd 100644 --- 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 @@ -3,4 +3,4 @@ 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 +internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 9003e3d69..d1b86b95d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,6 +3,9 @@ package com.nuvio.app import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.request.crossfade import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.features.addons.AddonsScreen import com.nuvio.app.features.home.HomeScreen @@ -26,6 +29,11 @@ fun AppScreen( @Composable @Preview fun App() { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .crossfade(true) + .build() + } NuvioTheme { AppScreen(tab = AppScreenTab.Addons) } 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 74644ab7a..a0a55147f 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 @@ -11,11 +11,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.width @@ -54,12 +54,12 @@ fun NuvioScreen( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .windowInsetsPadding(WindowInsets.safeDrawing), + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), contentPadding = PaddingValues( start = 18.dp, top = 12.dp + nuvioPlatformExtraTopPadding, end = 18.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 22.dp, + bottom = 22.dp, ), verticalArrangement = Arrangement.spacedBy(16.dp), content = content, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 6000e2799..8969826d9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -5,4 +5,4 @@ internal expect object AddonStorage { fun saveInstalledAddonUrls(urls: List) } -internal expect suspend fun httpGetText(url: String): String +expect suspend fun httpGetText(url: String): String diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt new file mode 100644 index 000000000..8d2d82e48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -0,0 +1,56 @@ +package com.nuvio.app.features.home + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object HomeCatalogParser { + private val json = Json { + ignoreUnknownKeys = true + } + + fun parseCatalog(payload: String): List { + val root = json.parseToJsonElement(payload).jsonObject + return root.array("metas") + .mapNotNull { element -> + val meta = element as? JsonObject ?: return@mapNotNull null + val id = meta.string("id") + val type = meta.string("type") + val name = meta.string("name") + + if (id.isNullOrBlank() || type.isNullOrBlank() || name.isNullOrBlank()) { + return@mapNotNull null + } + + MetaPreview( + id = id, + type = type, + name = name, + poster = meta.string("poster"), + posterShape = meta.string("posterShape").toPosterShape(), + description = meta.string("description"), + releaseInfo = meta.string("releaseInfo"), + imdbRating = meta.string("imdbRating"), + genres = meta.array("genres").mapNotNull { genre -> + genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } + }, + ) + } + } + + private fun JsonObject.string(name: String): String? = + this[name]?.jsonPrimitive?.contentOrNull + + private fun JsonObject.array(name: String): JsonArray = + this[name] as? JsonArray ?: JsonArray(emptyList()) + + private fun String?.toPosterShape(): PosterShape = + when (this?.lowercase()) { + "square" -> PosterShape.Square + "landscape" -> PosterShape.Landscape + else -> PosterShape.Poster + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt new file mode 100644 index 000000000..7c74fe1bd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.addons.ManagedAddon + +data class MetaPreview( + val id: String, + val type: String, + val name: String, + val poster: String? = null, + val posterShape: PosterShape = PosterShape.Poster, + val description: String? = null, + val releaseInfo: String? = null, + val imdbRating: String? = null, + val genres: List = emptyList(), +) + +enum class PosterShape { + Poster, + Square, + Landscape, +} + +data class HomeCatalogSection( + val key: String, + val title: String, + val subtitle: String, + val addonName: String, + val type: String, + val manifestUrl: String, + val catalogId: String, + val items: List, +) + +data class HomeUiState( + val isLoading: Boolean = false, + val sections: List = emptyList(), + val errorMessage: String? = null, +) + +internal data class CatalogRequest( + val addon: ManagedAddon, + val catalogId: String, + val catalogName: String, + val type: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt new file mode 100644 index 000000000..8db5fa7ae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.httpGetText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +object HomeRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var lastRequestKey: String? = null + + fun refresh(addons: List, force: Boolean = false) { + val requests = buildCatalogRequests(addons) + val requestKey = requests.joinToString(separator = "|") { request -> + "${request.addon.manifestUrl}:${request.type}:${request.catalogId}" + } + + if (!force && requestKey == lastRequestKey) return + lastRequestKey = requestKey + + if (requests.isEmpty()) { + _uiState.value = HomeUiState( + isLoading = false, + sections = emptyList(), + errorMessage = null, + ) + return + } + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + scope.launch { + val results = requests.map { request -> + async { + runCatching { request.toSection() } + } + }.awaitAll() + + val sections = results.mapNotNull { it.getOrNull() } + val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } + + _uiState.value = HomeUiState( + isLoading = false, + sections = sections, + errorMessage = if (sections.isEmpty()) firstFailure else null, + ) + } + } + + private fun buildCatalogRequests(addons: List): List = + addons.mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + addon to manifest + }.flatMap { (addon, manifest) -> + manifest.catalogs + .filter { catalog -> catalog.extra.none { it.isRequired } } + .map { catalog -> + CatalogRequest( + addon = addon, + catalogId = catalog.id, + catalogName = catalog.name, + type = catalog.type, + ) + } + } + + private suspend fun CatalogRequest.toSection(): HomeCatalogSection { + val manifest = requireNotNull(addon.manifest) + val catalogUrl = buildCatalogUrl( + manifestUrl = manifest.transportUrl, + type = type, + catalogId = catalogId, + ) + val payload = httpGetText(catalogUrl) + val items = HomeCatalogParser.parseCatalog(payload).take(12) + require(items.isNotEmpty()) { "No feed items returned for $catalogName." } + + return HomeCatalogSection( + key = "${manifest.id}:$type:$catalogId", + title = catalogName, + subtitle = manifest.name, + addonName = manifest.name, + type = type, + manifestUrl = manifest.transportUrl, + catalogId = catalogId, + items = items, + ) + } +} + +private fun buildCatalogUrl( + manifestUrl: String, + type: String, + catalogId: String, +): String { + val baseUrl = manifestUrl + .substringBefore("?") + .removeSuffix("/manifest.json") + return "$baseUrl/catalog/$type/$catalogId.json" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index b1900db0a..b5fdaa57d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -1,42 +1,100 @@ package com.nuvio.app.features.home -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioIconActionButton 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.NuvioSurfaceCard +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.home.components.HomeCatalogRowSection +import com.nuvio.app.features.home.components.HomeEmptyStateCard @Composable fun HomeScreen( modifier: Modifier = Modifier, ) { + LaunchedEffect(Unit) { + AddonRepository.initialize() + } + + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() + + val catalogRefreshKey = remember(addonsUiState.addons) { + addonsUiState.addons.mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + buildString { + append(manifest.transportUrl) + append(':') + append(manifest.catalogs.joinToString(separator = ",") { catalog -> + "${catalog.type}:${catalog.id}:${catalog.extra.count { it.isRequired }}" + }) + } + } + } + + LaunchedEffect(catalogRefreshKey) { + HomeRepository.refresh(addonsUiState.addons) + } + NuvioScreen(modifier = modifier) { item { - NuvioScreenHeader(title = "Home") - } - item { - NuvioSectionLabel(text = "OVERVIEW") - } - item { - NuvioSurfaceCard { - Text( - text = "Home screen content comes next.", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Shared cards, spacing and typography are now aligned with the Addons screen so the next feature can reuse the same shell.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + NuvioScreenHeader( + title = "Home", + ) { + NuvioIconActionButton( + icon = Icons.Rounded.Refresh, + contentDescription = "Refresh catalog rows", + onClick = { HomeRepository.refresh(addonsUiState.addons, force = true) }, ) } } + + when { + addonsUiState.addons.none { it.manifest != null } -> { + item { + HomeEmptyStateCard( + title = "No active addons", + message = "Install and validate at least one addon before loading catalog rows on Home.", + ) + } + } + + homeUiState.isLoading && homeUiState.sections.isEmpty() -> { + item { + HomeEmptyStateCard( + title = "Loading catalogs", + message = "Pulling feed-compatible catalogs from your installed addons.", + ) + } + } + + homeUiState.sections.isEmpty() -> { + item { + HomeEmptyStateCard( + title = "No home rows available", + message = homeUiState.errorMessage + ?: "Installed addons do not currently expose board-compatible catalogs without required extras.", + ) + } + } + + else -> { + items( + count = homeUiState.sections.size, + key = { index -> homeUiState.sections[index].key }, + ) { index -> + HomeCatalogRowSection( + section = homeUiState.sections[index], + ) + } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt new file mode 100644 index 000000000..93fa0e299 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.home.HomeCatalogSection + +@Composable +fun HomeCatalogRowSection( + section: HomeCatalogSection, + modifier: Modifier = Modifier, + onViewAllClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + HomeCatalogSectionHeader( + title = section.title, + subtitle = section.subtitle, + onViewAllClick = onViewAllClick, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + items( + items = section.items, + key = { item -> item.id }, + ) { item -> + HomePosterCard(item = item) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSectionHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSectionHeader.kt new file mode 100644 index 000000000..6d52f089a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSectionHeader.kt @@ -0,0 +1,100 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun HomeCatalogSectionHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onViewAllClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Box( + modifier = Modifier + .padding(top = 8.dp) + .width(82.dp) + .height(6.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(999.dp), + ), + ) + Text( + text = subtitle, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ViewAllPill( + onClick = onViewAllClick, + ) + } +} + +@Composable +private fun ViewAllPill( + onClick: (() -> Unit)?, +) { + Row( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(20.dp), + ) + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ) + .padding(horizontal = 18.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "View All", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt new file mode 100644 index 000000000..8cccc4845 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt @@ -0,0 +1,90 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape + +@Composable +fun HomePosterCard( + item: MetaPreview, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.width(142.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(item.posterShape.aspectRatio) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + if (item.poster != null) { + AsyncImage( + model = item.poster, + contentDescription = item.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = item.name, + modifier = Modifier.padding(horizontal = 14.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val detailLine = listOfNotNull(item.releaseInfo, item.imdbRating?.let { "IMDb $it" }).joinToString(" • ") + if (detailLine.isNotBlank()) { + Text( + text = detailLine, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Box(modifier = Modifier.height(0.dp)) + } + } +} + +private val PosterShape.aspectRatio: Float + get() = when (this) { + PosterShape.Poster -> 0.675f + PosterShape.Square -> 1f + PosterShape.Landscape -> 1.77f + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeStateCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeStateCards.kt new file mode 100644 index 000000000..482d85a14 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeStateCards.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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 +import com.nuvio.app.core.ui.NuvioPrimaryButton +import com.nuvio.app.core.ui.NuvioSurfaceCard + +@Composable +fun HomeEmptyStateCard( + title: String, + message: String, + actionLabel: String? = null, + onActionClick: (() -> Unit)? = null, +) { + NuvioSurfaceCard { + 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, + ) + if (actionLabel != null && onActionClick != null) { + Spacer(modifier = Modifier.height(16.dp)) + NuvioPrimaryButton( + text = actionLabel, + onClick = onActionClick, + ) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index 6f3f71751..100f1017a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -11,7 +11,7 @@ private fun nuvioViewController( AppScreen(tab = tab) } }.apply { - view.backgroundColor = UIColor.blackColor + view.backgroundColor = UIColor(red = 0.008, green = 0.016, blue = 0.016, alpha = 1.0) } fun HomeViewController() = nuvioViewController(tab = AppScreenTab.Home) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 705a5c09e..e132c7c77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,11 @@ androidx-espresso = "3.7.0" androidx-lifecycle = "2.9.6" androidx-testExt = "1.3.0" composeMultiplatform = "1.10.0" +coil = "3.4.0" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" +ktor = "3.4.1" material3 = "1.10.0-alpha05" [libraries] @@ -33,7 +35,11 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1a37802bd..015bdfc24 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -5,7 +5,7 @@ import ComposeApp struct HomeComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { let controller = MainViewControllerKt.HomeViewController() - controller.view.backgroundColor = .black + controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) return controller } @@ -15,7 +15,7 @@ struct HomeComposeView: UIViewControllerRepresentable { struct AddonsComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { let controller = MainViewControllerKt.AddonsViewController() - controller.view.backgroundColor = .black + controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) return controller } @@ -26,17 +26,19 @@ struct ContentView: View { var body: some View { TabView { HomeComposeView() + .ignoresSafeArea() .tabItem { Label("Home", systemImage: "house.fill") } AddonsComposeView() + .ignoresSafeArea() .tabItem { Label("Addons", systemImage: "puzzlepiece.extension.fill") } } - .background(Color.black.ignoresSafeArea()) - .toolbarBackground(Color.black, for: .tabBar) + .background(Color(red: 0.008, green: 0.016, blue: 0.016).ignoresSafeArea()) + .toolbarBackground(Color(red: 0.039, green: 0.051, blue: 0.051), for: .tabBar) .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(.dark, for: .tabBar) } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index f23a6b099..64f6d056e 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -6,7 +6,7 @@ struct iOSApp: App { init() { let tabAppearance = UITabBarAppearance() tabAppearance.configureWithOpaqueBackground() - tabAppearance.backgroundColor = UIColor.black + tabAppearance.backgroundColor = UIColor(red: 0.039, green: 0.051, blue: 0.051, alpha: 1.0) UITabBar.appearance().standardAppearance = tabAppearance UITabBar.appearance().scrollEdgeAppearance = tabAppearance