From 9545e4d61940325719d0ed125314cf2985947d13 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 20 Apr 2026 18:12:45 +0200 Subject: [PATCH] refactor: remove forced common PosterCardContent and inline them in the corresponding app ui module --- .../purefin/ui/common/card/PosterCard.kt | 168 ++++++++++++----- .../purefin/ui/common/card/PosterCard.kt | 169 +++++++++++++----- .../ui/common/card/PosterCardMappingTest.kt | 111 ------------ .../ui/common/card/PosterCardContent.kt | 166 ----------------- 4 files changed, 244 insertions(+), 370 deletions(-) delete mode 100644 app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt delete mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt index 9084f4f4..c266c8a6 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt @@ -1,15 +1,40 @@ package hu.bbara.purefin.ui.common.card +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import hu.bbara.purefin.core.model.MediaKind +import androidx.compose.ui.unit.sp import hu.bbara.purefin.core.ui.model.EpisodeUiModel import hu.bbara.purefin.core.ui.model.MediaUiModel import hu.bbara.purefin.core.ui.model.MovieUiModel import hu.bbara.purefin.core.ui.model.SeriesUiModel -import hu.bbara.purefin.feature.browse.home.PosterItem +import hu.bbara.purefin.ui.common.badge.WatchStateBadge +import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import java.util.UUID @Composable @@ -50,51 +75,102 @@ fun PosterCard( ) } -private fun PosterItem.toPosterCardModel(): PosterCardModel { - return PosterCardModel( - title = title, - secondaryText = secondaryText, - imageUrl = imageUrl, - mediaKind = type, - badge = when (type) { - MediaKind.MOVIE -> { - val movie = requireNotNull(movie) - PosterCardBadge.WatchState( - watched = movie.watched, - started = (movie.progress ?: 0.0) > 0.0 - ) - } - - MediaKind.EPISODE -> { - val episode = requireNotNull(episode) - PosterCardBadge.WatchState( - watched = episode.watched, - started = (episode.progress ?: 0.0) > 0.0 - ) - } - - MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes( - count = requireNotNull(series).unwatchedEpisodeCount - ) - - else -> PosterCardBadge.None - } - ) -} - -private fun PosterItem.open( - onMovieSelected: (UUID) -> Unit, - onSeriesSelected: (UUID) -> Unit, - onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +@Composable +fun PosterCardContent( + model: MediaUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + posterWidth: Dp = 144.dp, + contentScale: Float = 1f, + showSecondaryText: Boolean = false, + indicatorSize: Int = 28, + indicatorPadding: Dp = 8.dp, + onFocused: () -> Unit = {}, + focusedScale: Float = 1f, + focusedBorderWidth: Dp = 1.dp, + focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f) ) { - when (type) { - MediaKind.MOVIE -> onMovieSelected(id) - MediaKind.SERIES -> onSeriesSelected(id) - MediaKind.EPISODE -> { - val ep = requireNotNull(episode) - onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) - } + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (isFocused) focusedScale else 1f, + label = "scale" + ) - else -> Unit + Column( + modifier = modifier + .width(posterWidth) + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = focusedTransformOrigin + } + ) { + Box { + PurefinAsyncImage( + model = model.primaryImageUrl, + contentDescription = null, + modifier = imageModifier + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(14.dp)) + .border( + width = if (isFocused) focusedBorderWidth else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(14.dp) + ) + .background(scheme.surfaceVariant) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) { + onFocused() + } + } + .clickable(onClick = onClick), + contentScale = ContentScale.Crop + ) + when (model) { + is MovieUiModel, is EpisodeUiModel -> { + WatchStateBadge( + size = indicatorSize, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(indicatorPadding), + watched = model.watched, + started = (model.progress ?: 0f) > 0f + ) + } + else -> Unit + } + } + Column( + modifier = Modifier.padding( + top = 8.dp, + start = 4.dp, + end = 4.dp, + bottom = 8.dp + ) + ) { + Text( + text = model.primaryText, + color = scheme.onBackground, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + model.secondaryText + .takeIf { showSecondaryText } + ?.takeIf { it.isNotBlank() } + ?.let { text -> + Text( + text = text, + color = scheme.onSurfaceVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } } diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt b/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt index cba46fe6..d2ba6227 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt @@ -1,12 +1,40 @@ package hu.bbara.purefin.ui.common.card +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import hu.bbara.purefin.core.model.MediaKind +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import hu.bbara.purefin.core.ui.model.EpisodeUiModel import hu.bbara.purefin.core.ui.model.MediaUiModel import hu.bbara.purefin.core.ui.model.MovieUiModel import hu.bbara.purefin.core.ui.model.SeriesUiModel -import hu.bbara.purefin.feature.browse.home.PosterItem +import hu.bbara.purefin.ui.common.badge.WatchStateBadge +import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import java.util.UUID @Composable @@ -24,58 +52,105 @@ fun PosterCard( when (item) { is MovieUiModel -> onMovieSelected(item.id) is SeriesUiModel -> onSeriesSelected(item.id) - else -> Unit + is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id) } }, modifier = modifier ) } -internal fun PosterItem.toPosterCardModel(): PosterCardModel { - return PosterCardModel( - title = title, - secondaryText = secondaryText, - imageUrl = imageUrl, - mediaKind = type, - badge = when (type) { - MediaKind.MOVIE -> { - val movie = requireNotNull(movie) - PosterCardBadge.WatchState( - watched = movie.watched, - started = (movie.progress ?: 0.0) > 0.0 - ) - } - - MediaKind.EPISODE -> { - val episode = requireNotNull(episode) - PosterCardBadge.WatchState( - watched = episode.watched, - started = (episode.progress ?: 0.0) > 0.0 - ) - } - - MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes( - count = requireNotNull(series).unwatchedEpisodeCount - ) - - else -> PosterCardBadge.None - } - ) -} - -private fun PosterItem.open( - onMovieSelected: (UUID) -> Unit, - onSeriesSelected: (UUID) -> Unit, - onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +@Composable +fun PosterCardContent( + model: MediaUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + posterWidth: Dp = 144.dp, + showSecondaryText: Boolean = false, + indicatorSize: Int = 28, + indicatorPadding: Dp = 8.dp, + onFocused: () -> Unit = {}, ) { - when (type) { - MediaKind.MOVIE -> onMovieSelected(id) - MediaKind.SERIES -> onSeriesSelected(id) - MediaKind.EPISODE -> { - val ep = requireNotNull(episode) - onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) - } + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.07f else 1f, + label = "scale" + ) - else -> Unit + Column( + modifier = modifier + .width(posterWidth) + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) { + Box { + PurefinAsyncImage( + model = model.primaryImageUrl, + contentDescription = null, + modifier = imageModifier + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(14.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(14.dp) + ) + .background(scheme.surfaceVariant) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) { + onFocused() + } + } + .clickable(onClick = onClick), + contentScale = ContentScale.Crop + ) + when (model) { + is MovieUiModel, is EpisodeUiModel -> { + WatchStateBadge( + size = indicatorSize, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(indicatorPadding), + watched = model.watched, + started = (model.progress ?: 0f) > 0f + ) + } + else -> Unit + } + } + Column( + modifier = Modifier.padding( + top = 8.dp, + start = 4.dp, + end = 4.dp, + bottom = 8.dp + ) + ) { + Text( + text = model.primaryText, + color = scheme.onBackground, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + model.secondaryText + .takeIf { showSecondaryText } + ?.takeIf { it.isNotBlank() } + ?.let { text -> + Text( + text = text, + color = scheme.onSurfaceVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } } diff --git a/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt b/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt deleted file mode 100644 index 2aa6a124..00000000 --- a/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package hu.bbara.purefin.ui.common.card - -import hu.bbara.purefin.core.model.CastMember -import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.core.model.MediaKind -import hu.bbara.purefin.core.model.Movie -import hu.bbara.purefin.core.model.Season -import hu.bbara.purefin.core.model.Series -import hu.bbara.purefin.feature.browse.home.PosterItem -import java.util.UUID -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PosterCardMappingTest { - @Test - fun `maps movie poster item to watch-state card model`() { - val movie = Movie( - id = UUID.fromString("11111111-1111-1111-1111-111111111111"), - libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"), - title = "Blade Runner 2049", - progress = 18.0, - watched = false, - year = "2017", - rating = "16+", - runtime = "2h 44m", - format = "Dolby Vision", - synopsis = "Synthetic future noir.", - imageUrlPrefix = "https://example.test/movie/", - audioTrack = "English 5.1", - subtitles = "English CC", - cast = emptyList() - ) - - val model = PosterItem(type = MediaKind.MOVIE, movie = movie).toPosterCardModel() - - assertEquals("Blade Runner 2049", model.title) - assertEquals("2017", model.secondaryText) - assertEquals("https://example.test/movie/Primary", model.imageUrl) - assertEquals(MediaKind.MOVIE, model.mediaKind) - assertTrue(model.badge is PosterCardBadge.WatchState) - val badge = model.badge as PosterCardBadge.WatchState - assertEquals(false, badge.watched) - assertEquals(true, badge.started) - } - - @Test - fun `maps series poster item to unwatched badge card model`() { - val series = Series( - id = UUID.fromString("33333333-3333-3333-3333-333333333333"), - libraryId = UUID.fromString("44444444-4444-4444-4444-444444444444"), - name = "Severance", - synopsis = "Corporate sci-fi.", - year = "2025", - imageUrlPrefix = "https://example.test/series/", - unwatchedEpisodeCount = 12, - seasonCount = 2, - seasons = listOf( - Season( - id = UUID.fromString("55555555-5555-5555-5555-555555555555"), - seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"), - name = "Season 1", - index = 1, - unwatchedEpisodeCount = 9, - episodeCount = 9, - episodes = emptyList() - ) - ), - cast = listOf(CastMember("Adam Scott", "Mark", null)) - ) - - val model = PosterItem(type = MediaKind.SERIES, series = series).toPosterCardModel() - - assertEquals("Severance", model.title) - assertEquals("2025", model.secondaryText) - assertEquals(MediaKind.SERIES, model.mediaKind) - assertTrue(model.badge is PosterCardBadge.UnwatchedEpisodes) - val badge = model.badge as PosterCardBadge.UnwatchedEpisodes - assertEquals(12, badge.count) - } - - @Test - fun `maps episode poster item to watch-state card model`() { - val episode = Episode( - id = UUID.fromString("66666666-6666-6666-6666-666666666666"), - seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"), - seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"), - index = 4, - title = "The You You Are", - synopsis = "Office mystery.", - releaseDate = "2025", - rating = "16+", - runtime = "53m", - progress = 0.0, - watched = true, - format = "4K", - imageUrlPrefix = "https://example.test/episode/", - cast = emptyList() - ) - - val model = PosterItem(type = MediaKind.EPISODE, episode = episode).toPosterCardModel() - - assertEquals("The You You Are", model.title) - assertEquals("2025", model.secondaryText) - assertEquals(MediaKind.EPISODE, model.mediaKind) - assertTrue(model.badge is PosterCardBadge.WatchState) - val badge = model.badge as PosterCardBadge.WatchState - assertEquals(true, badge.watched) - assertEquals(false, badge.started) - } -} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt deleted file mode 100644 index be86bf10..00000000 --- a/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt +++ /dev/null @@ -1,166 +0,0 @@ -package hu.bbara.purefin.ui.common.card - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -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.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import hu.bbara.purefin.core.model.MediaKind -import hu.bbara.purefin.core.ui.model.EpisodeUiModel -import hu.bbara.purefin.core.ui.model.MediaUiModel -import hu.bbara.purefin.core.ui.model.MovieUiModel -import hu.bbara.purefin.ui.common.badge.WatchStateBadge -import hu.bbara.purefin.ui.common.image.PurefinAsyncImage - -data class PosterCardModel( - val title: String, - val secondaryText: String = "", - val imageUrl: String?, - val mediaKind: MediaKind, - val badge: PosterCardBadge = PosterCardBadge.None -) - -sealed interface PosterCardBadge { - data object None : PosterCardBadge - - data class WatchState( - val watched: Boolean, - val started: Boolean - ) : PosterCardBadge - - data class UnwatchedEpisodes( - val count: Int - ) : PosterCardBadge -} - -@Composable -fun PosterCardContent( - model: MediaUiModel, - onClick: () -> Unit, - modifier: Modifier = Modifier, - imageModifier: Modifier = Modifier, - posterWidth: Dp = 144.dp, - contentScale: Float = 1f, - showSecondaryText: Boolean = false, - indicatorSize: Int = 28, - indicatorPadding: Dp = 8.dp, - onFocused: () -> Unit = {}, - focusedScale: Float = 1f, - focusedBorderWidth: Dp = 1.dp, - focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f) -) { - val scheme = MaterialTheme.colorScheme - var isFocused by remember { mutableStateOf(false) } - val shape = RoundedCornerShape((14f * contentScale).dp) - val unfocusedBorderWidth = (1f * contentScale).dp - val scaledFocusedBorderWidth = focusedBorderWidth * contentScale - val contentTopPadding = (8f * contentScale).dp - val contentHorizontalPadding = (4f * contentScale).dp - val contentBottomPadding = (8f * contentScale).dp - val titleFontSize = (13f * contentScale).sp - val secondaryFontSize = (11f * contentScale).sp - val scale by animateFloatAsState( - targetValue = if (isFocused) focusedScale else 1f, - label = "scale" - ) - - Column( - modifier = modifier - .width(posterWidth) - .graphicsLayer { - scaleX = scale - scaleY = scale - transformOrigin = focusedTransformOrigin - } - ) { - Box { - PurefinAsyncImage( - model = model.primaryImageUrl, - contentDescription = null, - modifier = imageModifier - .aspectRatio(2f / 3f) - .clip(shape) - .border( - width = if (isFocused) scaledFocusedBorderWidth else unfocusedBorderWidth, - color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), - shape = shape - ) - .background(scheme.surfaceVariant) - .onFocusChanged { - isFocused = it.isFocused - if (it.isFocused) { - onFocused() - } - } - .clickable(onClick = onClick), - contentScale = ContentScale.Crop - ) - when (model) { - is MovieUiModel, is EpisodeUiModel -> { - WatchStateBadge( - size = indicatorSize, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(indicatorPadding), - watched = model.watched, - started = (model.progress ?: 0f) > 0f - ) - } - else -> Unit - } - } - Column( - modifier = Modifier.padding( - top = contentTopPadding, - start = contentHorizontalPadding, - end = contentHorizontalPadding, - bottom = contentBottomPadding - ) - ) { - Text( - text = model.primaryText, - color = scheme.onBackground, - fontSize = titleFontSize, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - model.secondaryText - .takeIf { showSecondaryText } - ?.takeIf { it.isNotBlank() } - ?.let { text -> - Text( - text = text, - color = scheme.onSurfaceVariant, - fontSize = secondaryFontSize, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } -}