mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Enhance home screen functionality with catalog parsing and UI improvements
This commit is contained in:
parent
c8efcdc66b
commit
0732854d3e
18 changed files with 598 additions and 36 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ captures
|
|||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
Docs
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ internal expect object AddonStorage {
|
|||
fun saveInstalledAddonUrls(urls: List<String>)
|
||||
}
|
||||
|
||||
internal expect suspend fun httpGetText(url: String): String
|
||||
expect suspend fun httpGetText(url: String): String
|
||||
|
|
|
|||
|
|
@ -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<MetaPreview> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = 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<MetaPreview>,
|
||||
)
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val sections: List<HomeCatalogSection> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
internal data class CatalogRequest(
|
||||
val addon: ManagedAddon,
|
||||
val catalogId: String,
|
||||
val catalogName: String,
|
||||
val type: String,
|
||||
)
|
||||
|
|
@ -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<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var lastRequestKey: String? = null
|
||||
|
||||
fun refresh(addons: List<ManagedAddon>, 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<ManagedAddon>): List<CatalogRequest> =
|
||||
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"
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue