Add wide cast and production layouts

This commit is contained in:
tapframe 2026-06-21 12:57:29 +05:30
parent 6c9b926b0e
commit f4a2ce8963
2 changed files with 675 additions and 75 deletions

View file

@ -6,12 +6,15 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -257,68 +260,356 @@ private fun PersonDetailContent(
.background(accentGradient),
)
AnimatedVisibility(
visible = true,
enter = fadeIn(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 48.dp),
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val useWideLayout = maxWidth >= PERSON_DETAIL_WIDE_LAYOUT_MIN_WIDTH
AnimatedVisibility(
visible = true,
enter = fadeIn(),
) {
HeroSection(
person = person,
collapseProgress = collapseProgress,
fallbackProfilePhoto = initialProfilePhoto,
avatarTransitionKey = avatarTransitionKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_popular),
items = popularCredits,
if (useWideLayout) {
WidePersonDetailContent(
person = person,
popularCredits = popularCredits,
latestCredits = latestCredits,
upcomingCredits = upcomingCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
onOpenMeta = onOpenMeta,
fallbackProfilePhoto = initialProfilePhoto,
avatarTransitionKey = avatarTransitionKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 48.dp),
) {
HeroSection(
person = person,
collapseProgress = collapseProgress,
fallbackProfilePhoto = initialProfilePhoto,
avatarTransitionKey = avatarTransitionKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_latest),
items = latestCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
}
if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_popular),
items = popularCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
}
if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
}
if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_latest),
items = latestCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
}
Spacer(modifier = Modifier.height(32.dp))
if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
}
private val PERSON_DETAIL_WIDE_LAYOUT_MIN_WIDTH = 900.dp
private val PERSON_DETAIL_WIDE_SIDEBAR_WIDTH = 392.dp
private const val HERO_COLLAPSE_SCROLL_RANGE = 220f
private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun WidePersonDetailContent(
person: PersonDetail,
popularCredits: List<MetaPreview>,
latestCredits: List<MetaPreview>,
upcomingCredits: List<MetaPreview>,
watchedKeys: Set<String>,
onOpenMeta: (MetaPreview) -> Unit,
fallbackProfilePhoto: String?,
avatarTransitionKey: String,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 34.dp),
) {
PersonIdentitySidebar(
person = person,
fallbackProfilePhoto = fallbackProfilePhoto,
avatarTransitionKey = avatarTransitionKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
modifier = Modifier
.width(PERSON_DETAIL_WIDE_SIDEBAR_WIDTH)
.fillMaxHeight(),
)
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)),
)
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = 40.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(34.dp),
) {
if (popularCredits.isNotEmpty()) {
DetailPosterRailSection(
title = stringResource(Res.string.person_popular),
items = popularCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 0.dp,
onPosterClick = onOpenMeta,
)
}
if (latestCredits.isNotEmpty()) {
DetailPosterRailSection(
title = stringResource(Res.string.person_latest),
items = latestCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 0.dp,
onPosterClick = onOpenMeta,
)
}
if (upcomingCredits.isNotEmpty()) {
DetailPosterRailSection(
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
watchedKeys = watchedKeys,
headerHorizontalPadding = 0.dp,
onPosterClick = onOpenMeta,
)
}
}
}
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun PersonIdentitySidebar(
person: PersonDetail,
fallbackProfilePhoto: String?,
avatarTransitionKey: String,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
modifier: Modifier = Modifier,
) {
val accentColor = MaterialTheme.colorScheme.primary
val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto
val platformContext = LocalPlatformContext.current
val avatarRequest = if (!avatarUrl.isNullOrBlank()) {
remember(platformContext, avatarUrl, avatarTransitionKey) {
ImageRequest.Builder(platformContext)
.data(avatarUrl)
.memoryCacheKey(avatarTransitionKey)
.placeholderMemoryCacheKey(avatarTransitionKey)
.diskCacheKey(avatarUrl)
.build()
}
} else {
null
}
val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(key = avatarTransitionKey),
animatedVisibilityScope = animatedVisibilityScope,
)
}
} else {
Modifier
}
val credits = remember(person.movieCredits, person.tvCredits) {
(person.movieCredits + person.tvCredits).distinctBy { it.id }
}
val creditSummary = remember(credits) {
buildCreditSummary(credits)
}
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(start = 40.dp, end = 36.dp, top = 40.dp, bottom = 42.dp),
verticalArrangement = Arrangement.spacedBy(22.dp),
) {
Box(
modifier = Modifier.size(162.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.matchParentSize()
.clip(CircleShape)
.background(accentColor.copy(alpha = 0.14f)),
)
Box(
modifier = Modifier
.then(avatarSharedElementModifier)
.size(148.dp)
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (!avatarUrl.isNullOrBlank()) {
AsyncImage(
model = avatarRequest ?: avatarUrl,
contentDescription = person.name,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = person.name.initials(),
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(11.dp)) {
Text(
text = person.name,
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.5).sp,
lineHeight = 34.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
person.knownFor?.takeIf { it.isNotBlank() }?.let { knownFor ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(accentColor.copy(alpha = 0.14f))
.padding(horizontal = 12.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(accentColor),
)
Text(
text = stringResource(Res.string.person_known_for, knownFor).replace(": ", " "),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = accentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(15.dp)) {
person.birthday?.let { birthday ->
PersonSidebarFact(
label = "Born",
value = personBirthLine(birthday = birthday, deathday = person.deathday),
)
}
person.placeOfBirth?.takeIf { it.isNotBlank() }?.let { place ->
PersonSidebarFact(label = "Place of birth", value = place)
}
if (creditSummary.isNotBlank()) {
PersonSidebarFact(label = "Credits", value = creditSummary)
}
}
person.biography?.takeIf { it.isNotBlank() }?.let { biography ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)),
)
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
SidebarLabel(text = "Biography")
Text(
text = biography,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun PersonSidebarFact(
label: String,
value: String,
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
SidebarLabel(text = label)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun SidebarLabel(text: String) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
letterSpacing = 1.0.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.70f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun HeroSection(
@ -725,6 +1016,50 @@ private fun PersonDetailError(
// ─── Utility ───
private fun String.initials(): String {
val parts = trim()
.split(" ")
.filter { it.isNotBlank() }
return parts
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.ifBlank { firstOrNull()?.uppercase() ?: "?" }
}
private fun buildCreditSummary(credits: List<MetaPreview>): String {
if (credits.isEmpty()) return ""
val firstYear = credits
.mapNotNull { it.rawReleaseDate?.take(4)?.toIntOrNull() }
.minOrNull()
return buildString {
append(credits.size)
append(if (credits.size == 1) " title" else " titles")
if (firstYear != null) {
append(" · since ")
append(firstYear)
}
}
}
private fun personBirthLine(birthday: String, deathday: String?): String {
val birthdayDisplay = formatDateForDisplay(birthday) ?: birthday
val deathDisplay = deathday?.let { formatDateForDisplay(it) ?: it }
val age = calculateAge(birthday, deathday)
return buildString {
append(birthdayDisplay)
if (deathDisplay != null) {
append(" · died ")
append(deathDisplay)
}
if (age != null) {
append(" · ")
append(age)
append(" years")
}
}
}
private fun calculateAge(birthday: String, deathday: String?): Int? {
val birthParts = birthday.split("-").mapNotNull { it.toIntOrNull() }
if (birthParts.size < 3) return null

View file

@ -2,16 +2,20 @@ package com.nuvio.app.features.details
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
@ -64,6 +68,9 @@ private sealed interface EntityBrowseUiState {
data class Success(val data: TmdbEntityBrowseData) : EntityBrowseUiState
}
private val ENTITY_BROWSE_WIDE_LAYOUT_MIN_WIDTH = 900.dp
private val ENTITY_BROWSE_WIDE_SIDEBAR_WIDTH = 392.dp
@Composable
fun TmdbEntityBrowseScreen(
entityKind: TmdbEntityKind,
@ -175,9 +182,90 @@ private fun EntityBrowseContent(
),
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val useWideLayout = maxWidth >= ENTITY_BROWSE_WIDE_LAYOUT_MIN_WIDTH
if (useWideLayout) {
WideEntityBrowseContent(
data = data,
watchedKeys = watchedKeys,
onOpenMeta = onOpenMeta,
)
} else if (data.rails.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 56.dp),
) {
EntityHeroSection(
header = data.header,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
data.rails.forEach { rail ->
DetailPosterRailSection(
title = entityRailTitle(rail),
items = rail.items,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
@Composable
private fun WideEntityBrowseContent(
data: TmdbEntityBrowseData,
watchedKeys: Set<String>,
onOpenMeta: (MetaPreview) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 34.dp),
) {
EntityIdentitySidebar(
header = data.header,
catalogueCount = data.rails.sumOf { it.items.size },
modifier = Modifier
.width(ENTITY_BROWSE_WIDE_SIDEBAR_WIDTH)
.fillMaxHeight(),
)
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)),
)
if (data.rails.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 40.dp),
contentAlignment = Alignment.Center,
) {
Text(
@ -189,46 +277,212 @@ private fun EntityBrowseContent(
} else {
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 56.dp),
.padding(start = 40.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(34.dp),
) {
EntityHeroSection(
header = data.header,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
data.rails.forEach { rail ->
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular)
TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated)
TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent)
}
val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel)
DetailPosterRailSection(
title = railTitle,
title = entityRailTitle(rail),
items = rail.items,
watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
headerHorizontalPadding = 0.dp,
onPosterClick = onOpenMeta,
)
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun EntityIdentitySidebar(
header: com.nuvio.app.features.tmdb.TmdbEntityHeader,
catalogueCount: Int,
modifier: Modifier = Modifier,
) {
val accentColor = MaterialTheme.colorScheme.primary
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(start = 40.dp, end = 36.dp, top = 40.dp, bottom = 42.dp),
verticalArrangement = Arrangement.spacedBy(22.dp),
) {
Text(
text = when (header.kind) {
TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
}.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.6.sp,
),
color = accentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Box(
modifier = Modifier.size(144.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(28.dp))
.background(accentColor.copy(alpha = 0.14f)),
)
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(24.dp))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.40f),
shape = RoundedCornerShape(24.dp),
)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (!header.logo.isNullOrBlank()) {
Box(
modifier = Modifier
.width(92.dp)
.height(62.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.White)
.padding(10.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = header.logo,
contentDescription = header.name,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Fit,
)
}
} else {
Text(
text = header.name.initials(),
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(
text = header.name,
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.5).sp,
lineHeight = 34.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
val metaLine = listOfNotNull(
header.secondaryLabel?.takeIf { it.isNotBlank() },
header.originCountry?.takeIf { it.isNotBlank() },
).joinToString(" · ")
if (metaLine.isNotBlank()) {
Text(
text = metaLine,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(15.dp)) {
header.originCountry?.takeIf { it.isNotBlank() }?.let { country ->
EntitySidebarFact(label = "Country", value = country)
}
header.secondaryLabel?.takeIf { it.isNotBlank() }?.let { label ->
EntitySidebarFact(label = "Type", value = label)
}
if (catalogueCount > 0) {
EntitySidebarFact(
label = "Catalogue",
value = "$catalogueCount ${if (catalogueCount == 1) "title" else "titles"}",
)
}
}
header.description?.takeIf { it.isNotBlank() }?.let { description ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)),
)
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
EntitySidebarLabel(text = "About")
Text(
text = description,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun EntitySidebarFact(
label: String,
value: String,
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
EntitySidebarLabel(text = label)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun EntitySidebarLabel(text: String) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
letterSpacing = 1.0.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.70f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun entityRailTitle(rail: com.nuvio.app.features.tmdb.TmdbEntityRail): String {
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular)
TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated)
TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent)
}
return stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel)
}
@Composable
private fun EntityHeroSection(
header: com.nuvio.app.features.tmdb.TmdbEntityHeader,
@ -419,3 +673,14 @@ private fun EntityBrowseError(
}
}
}
private fun String.initials(): String {
val parts = trim()
.split(" ")
.filter { it.isNotBlank() }
return parts
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.ifBlank { firstOrNull()?.uppercase() ?: "?" }
}