mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
refactor: remove forced common PosterCardContent and inline them in the corresponding app ui module
This commit is contained in:
@@ -1,15 +1,40 @@
|
|||||||
package hu.bbara.purefin.ui.common.card
|
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.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.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.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.EpisodeUiModel
|
||||||
import hu.bbara.purefin.core.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.ui.model.MediaUiModel
|
||||||
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
||||||
import hu.bbara.purefin.core.ui.model.SeriesUiModel
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -50,51 +75,102 @@ fun PosterCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun PosterItem.toPosterCardModel(): PosterCardModel {
|
@Composable
|
||||||
return PosterCardModel(
|
fun PosterCardContent(
|
||||||
title = title,
|
model: MediaUiModel,
|
||||||
secondaryText = secondaryText,
|
onClick: () -> Unit,
|
||||||
imageUrl = imageUrl,
|
modifier: Modifier = Modifier,
|
||||||
mediaKind = type,
|
imageModifier: Modifier = Modifier,
|
||||||
badge = when (type) {
|
posterWidth: Dp = 144.dp,
|
||||||
MediaKind.MOVIE -> {
|
contentScale: Float = 1f,
|
||||||
val movie = requireNotNull(movie)
|
showSecondaryText: Boolean = false,
|
||||||
PosterCardBadge.WatchState(
|
indicatorSize: Int = 28,
|
||||||
watched = movie.watched,
|
indicatorPadding: Dp = 8.dp,
|
||||||
started = (movie.progress ?: 0.0) > 0.0
|
onFocused: () -> Unit = {},
|
||||||
)
|
focusedScale: Float = 1f,
|
||||||
}
|
focusedBorderWidth: Dp = 1.dp,
|
||||||
|
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
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,
|
|
||||||
) {
|
) {
|
||||||
when (type) {
|
val scheme = MaterialTheme.colorScheme
|
||||||
MediaKind.MOVIE -> onMovieSelected(id)
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
MediaKind.SERIES -> onSeriesSelected(id)
|
val scale by animateFloatAsState(
|
||||||
MediaKind.EPISODE -> {
|
targetValue = if (isFocused) focusedScale else 1f,
|
||||||
val ep = requireNotNull(episode)
|
label = "scale"
|
||||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
package hu.bbara.purefin.ui.common.card
|
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.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.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.MediaUiModel
|
||||||
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
||||||
import hu.bbara.purefin.core.ui.model.SeriesUiModel
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -24,58 +52,105 @@ fun PosterCard(
|
|||||||
when (item) {
|
when (item) {
|
||||||
is MovieUiModel -> onMovieSelected(item.id)
|
is MovieUiModel -> onMovieSelected(item.id)
|
||||||
is SeriesUiModel -> onSeriesSelected(item.id)
|
is SeriesUiModel -> onSeriesSelected(item.id)
|
||||||
else -> Unit
|
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun PosterItem.toPosterCardModel(): PosterCardModel {
|
@Composable
|
||||||
return PosterCardModel(
|
fun PosterCardContent(
|
||||||
title = title,
|
model: MediaUiModel,
|
||||||
secondaryText = secondaryText,
|
onClick: () -> Unit,
|
||||||
imageUrl = imageUrl,
|
modifier: Modifier = Modifier,
|
||||||
mediaKind = type,
|
imageModifier: Modifier = Modifier,
|
||||||
badge = when (type) {
|
posterWidth: Dp = 144.dp,
|
||||||
MediaKind.MOVIE -> {
|
showSecondaryText: Boolean = false,
|
||||||
val movie = requireNotNull(movie)
|
indicatorSize: Int = 28,
|
||||||
PosterCardBadge.WatchState(
|
indicatorPadding: Dp = 8.dp,
|
||||||
watched = movie.watched,
|
onFocused: () -> Unit = {},
|
||||||
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,
|
|
||||||
) {
|
) {
|
||||||
when (type) {
|
val scheme = MaterialTheme.colorScheme
|
||||||
MediaKind.MOVIE -> onMovieSelected(id)
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
MediaKind.SERIES -> onSeriesSelected(id)
|
val scale by animateFloatAsState(
|
||||||
MediaKind.EPISODE -> {
|
targetValue = if (isFocused) 1.07f else 1f,
|
||||||
val ep = requireNotNull(episode)
|
label = "scale"
|
||||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user