diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt index 755c5ec..a455b9d 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreen.kt @@ -56,8 +56,7 @@ import dev.datlag.aniflow.common.* import dev.datlag.aniflow.other.StateSaver import dev.datlag.aniflow.ui.custom.EditFAB import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.GenreChip -import dev.datlag.aniflow.ui.navigation.screen.medium.component.CharacterCard -import dev.datlag.aniflow.ui.navigation.screen.medium.component.TranslateButton +import dev.datlag.aniflow.ui.navigation.screen.medium.component.* import dev.datlag.tooling.compose.ifTrue import dev.datlag.tooling.compose.onClick import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle @@ -75,9 +74,6 @@ fun MediumScreen(component: MediumComponent) { state = appBarState ) val coverImage by component.coverImage.collectAsStateWithLifecycle() - val isCollapsed by remember(appBarState) { - derivedStateOf { appBarState.collapsedFraction >= 0.99F } - } val ratingState = rememberUseCaseState() val userRating by component.rating.collectAsStateWithLifecycle() val dialogState by component.dialog.subscribeAsState() @@ -112,104 +108,14 @@ fun MediumScreen(component: MediumComponent) { Scaffold( modifier = Modifier.nestedScroll(scrollState.nestedScrollConnection), topBar = { - Box( - modifier = Modifier.fillMaxWidth() - ) { - val bannerImage by component.bannerImage.collectAsStateWithLifecycle() - - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .matchParentSize(), - model = bannerImage, - contentScale = ContentScale.Crop, - contentDescription = null, - error = rememberAsyncImagePainter( - model = coverImage.extraLarge, - contentScale = ContentScale.Crop, - error = rememberAsyncImagePainter( - model = coverImage.large, - contentScale = ContentScale.Crop - ) - ), - alpha = 1F - appBarState.collapsedFraction - ) - LargeTopAppBar( - navigationIcon = { - IconButton( - modifier = if (isCollapsed) { - Modifier - } else { - Modifier.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75F), CircleShape) - }, - onClick = { - component.back() - } - ) { - Icon( - imageVector = Icons.Default.ArrowBackIosNew, - contentDescription = null - ) - } - }, - title = { - val title by component.title.collectAsStateWithLifecycle() - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) - ) { - Text( - text = title.preferred(), - softWrap = true, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = if (!isCollapsed) { - LocalTextStyle.current.copy( - shadow = Shadow( - color = MaterialTheme.colorScheme.surface, - offset = Offset(4F, 4F), - blurRadius = 8F - ) - ) - } else { - LocalTextStyle.current - } - ) - title.notPreferred()?.let { - Text( - text = it, - softWrap = true, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = if (!isCollapsed) { - MaterialTheme.typography.labelMedium.copy( - shadow = Shadow( - color = MaterialTheme.colorScheme.surface, - offset = Offset(4F, 4F), - blurRadius = 8F - ) - ) - } else { - MaterialTheme.typography.labelMedium - } - ) - } - } - }, - scrollBehavior = scrollState, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent - ), - modifier = Modifier.hazeChild( - state = LocalHaze.current, - style = HazeMaterials.thin( - containerColor = MaterialTheme.colorScheme.surface - ) - ).fillMaxWidth() - ) - } + CollapsingToolbar( + state = appBarState, + scrollBehavior = scrollState, + bannerImageFlow = component.bannerImage, + coverImage = coverImage, + titleFlow = component.title, + onBack = { component.back() } + ) }, floatingActionButton = { val alreadyAdded by component.alreadyAdded.collectAsStateWithLifecycle() @@ -240,12 +146,6 @@ fun MediumScreen(component: MediumComponent) { CompositionLocalProvider( LocalPaddingValues provides LocalPadding().merge(it) ) { - val description by component.description.collectAsStateWithLifecycle() - var descriptionExpandable by remember(description) { mutableStateOf(false) } - var descriptionExpanded by remember(description) { mutableStateOf(false) } - val characters by component.characters.collectAsStateWithLifecycle() - val trailer by component.trailer.collectAsStateWithLifecycle() - LazyColumn( state = listState, modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), @@ -253,319 +153,51 @@ fun MediumScreen(component: MediumComponent) { contentPadding = LocalPadding(top = 16.dp) ) { item { - Row( - modifier = Modifier.fillParentMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - var coverShadow by remember(coverImage) { mutableStateOf(false) } - - AsyncImage( - modifier = Modifier - .width(140.dp) - .height(200.dp) - .ifTrue(coverShadow) { - shadow( - elevation = 8.dp, - shape = MaterialTheme.shapes.medium, - spotColor = MaterialTheme.colorScheme.primary - ) - }, - model = coverImage.extraLarge, - contentScale = ContentScale.Crop, - contentDescription = null, - placeholder = shimmerPainter(), - error = rememberAsyncImagePainter( - model = coverImage.large, - contentScale = ContentScale.Crop, - placeholder = shimmerPainter(), - error = rememberAsyncImagePainter( - model = coverImage.medium, - contentScale = ContentScale.Crop, - placeholder = shimmerPainter(), - onSuccess = { - coverShadow = true - } - ), - onSuccess = { - coverShadow = true - } - ), - onSuccess = { - coverShadow = true - } - ) - Column( - modifier = Modifier.weight(1F).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) - ) { - val format by component.format.collectAsStateWithLifecycle() - val episodes by component.episodes.collectAsStateWithLifecycle() - val duration by component.duration.collectAsStateWithLifecycle() - val status by component.status.collectAsStateWithLifecycle() - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.OndemandVideo, - contentDescription = null - ) - Text(text = stringResource(format.text())) - } - if (episodes > -1) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null - ) - Text(text = "$episodes Episodes") - } - } - if (duration > -1) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Timelapse, - contentDescription = null - ) - Text(text = "${duration}min / Episode") - } - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.RssFeed, - contentDescription = null - ) - Text(text = stringResource(status.text())) - } - } - } + CoverSection( + coverImage = coverImage, + formatFlow = component.format, + episodesFlow = component.episodes, + durationFlow = component.duration, + statusFlow = component.status, + modifier = Modifier.fillParentMaxWidth().padding(horizontal = 16.dp) + ) } item { - Row( - modifier = Modifier.fillParentMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround - ) { - val rated by component.rated.collectAsStateWithLifecycle() - val popular by component.popular.collectAsStateWithLifecycle() - val score by component.score.collectAsStateWithLifecycle() - - rated?.let { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(SharedRes.strings.rated), - style = MaterialTheme.typography.labelSmall - ) - Text( - text = "#${it.rank}", - style = MaterialTheme.typography.displaySmall - ) - } - } - popular?.let { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(SharedRes.strings.popular), - style = MaterialTheme.typography.labelSmall - ) - Text( - text = "#${it.rank}", - style = MaterialTheme.typography.displaySmall - ) - } - } - score?.let { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(SharedRes.strings.score), - style = MaterialTheme.typography.labelSmall - ) - Text( - text = "${it}%", - style = MaterialTheme.typography.displaySmall - ) - } - } - } + RatingSection( + ratedFlow = component.rated, + popularFlow = component.popular, + scoreFlow = component.score, + modifier = Modifier.fillParentMaxWidth().padding(16.dp) + ) } item { - val genres by component.genres.collectAsStateWithLifecycle() - - if (genres.isNotEmpty()) { - LazyRow( - modifier = Modifier.fillParentMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) - ) { - items(genres.toList()) { genre -> - GenreChip(label = genre) - } - } - } + GenreSection( + genreFlow = component.genres, + modifier = Modifier.fillParentMaxWidth() + ) } - if (!description.isNullOrBlank()) { - item { - Row( - modifier = Modifier.fillParentMaxWidth().padding(top = 16.dp).padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - modifier = Modifier.weight(1F), - text = stringResource(SharedRes.strings.description), - style = MaterialTheme.typography.headlineSmall - ) - TranslateButton(description!!) { text -> - component.descriptionTranslation(text) - } - } - } - item { - val translatedDescription by component.translatedDescription.collectAsStateWithLifecycle() - val animatedLines by animateIntAsState( - targetValue = if (descriptionExpanded) { - Int.MAX_VALUE - } else { - 3 - }, - animationSpec = tween() - ) - - Text( - modifier = Modifier.padding(horizontal = 16.dp).onClick { - descriptionExpanded = !descriptionExpanded - }, - text = (translatedDescription ?: description)!!.htmlToAnnotatedString(), - maxLines = max(animatedLines, 1), - softWrap = true, - overflow = TextOverflow.Ellipsis, - onTextLayout = { result -> - if (!descriptionExpanded) { - descriptionExpandable = result.hasVisualOverflow - } - } - ) - } - if (descriptionExpandable) { - item { - IconButton( - modifier = Modifier.fillParentMaxWidth(), - onClick = { - descriptionExpanded = !descriptionExpanded - } - ) { - val icon = if (descriptionExpanded) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - } - - Icon( - imageVector = icon, - contentDescription = null - ) - } - } + item { + DescriptionSection( + descriptionFlow = component.description, + translatedDescriptionFlow = component.translatedDescription, + modifier = Modifier.fillParentMaxWidth() + ) { translated -> + component.descriptionTranslation(translated) } } - if (characters.isNotEmpty()) { - item { - Text( - modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp), - text = stringResource(SharedRes.strings.characters), - style = MaterialTheme.typography.headlineSmall - ) - } - item { - LazyRow( - modifier = Modifier.fillParentMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) - ) { - items(characters.toList()) { char -> - CharacterCard( - char = char, - modifier = Modifier.width(96.dp).height(200.dp) - ) { - component.showCharacter(char) - } - } - } + item { + CharacterSection( + characterFlow = component.characters, + modifier = Modifier.fillParentMaxWidth() + ) { char -> + component.showCharacter(char) } } - - trailer?.let { t -> - item { - Text( - modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp), - text = stringResource(SharedRes.strings.trailer), - style = MaterialTheme.typography.headlineSmall - ) - } - item { - val uriHandler = LocalUriHandler.current - - trailer?.let { t -> - Card( - modifier = Modifier.fillParentMaxWidth().height(200.dp).padding(16.dp), - onClick = { - uriHandler.openUri(t.videoUrl ?: t.website) - } - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - AsyncImage( - modifier = Modifier.fillMaxSize(), - model = t.thumbnail, - contentDescription = null, - contentScale = ContentScale.Crop - ) - if (t.isYoutube) { - Image( - modifier = Modifier.size(48.dp), - painter = painterResource(SharedRes.images.youtube), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalContentColor.current) - ) - } else { - Icon( - modifier = Modifier.size(48.dp), - imageVector = Icons.Filled.PlayCircleFilled, - contentDescription = null - ) - } - } - } - } - } + item { + TrailerSection( + trailerFlow = component.trailer, + modifier = Modifier.fillParentMaxWidth() + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CharacterSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CharacterSection.kt new file mode 100644 index 0000000..ba32f06 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CharacterSection.kt @@ -0,0 +1,53 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.model.Character +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun CharacterSection( + characterFlow: StateFlow>, + modifier: Modifier = Modifier, + onClick: (Character) -> Unit +) { + val characters by characterFlow.collectAsStateWithLifecycle() + + if (characters.isNotEmpty()) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp), + text = stringResource(SharedRes.strings.characters), + style = MaterialTheme.typography.headlineSmall + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + ) { + items(characters.toList()) { char -> + CharacterCard( + char = char, + modifier = Modifier.width(96.dp).height(200.dp), + onClick = onClick + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CollapsingToolbar.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CollapsingToolbar.kt new file mode 100644 index 0000000..ce0b4b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CollapsingToolbar.kt @@ -0,0 +1,147 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +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.fillMaxWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import dev.chrisbanes.haze.hazeChild +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.datlag.aniflow.LocalHaze +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.common.notPreferred +import dev.datlag.aniflow.common.preferred +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.StateFlow + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun CollapsingToolbar( + state: TopAppBarState, + scrollBehavior: TopAppBarScrollBehavior, + bannerImageFlow: StateFlow, + coverImage: Medium.CoverImage, + titleFlow: StateFlow, + onBack: () -> Unit +) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + val bannerImage by bannerImageFlow.collectAsStateWithLifecycle() + val isCollapsed by remember(state) { + derivedStateOf { state.collapsedFraction >= 0.99F } + } + + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .matchParentSize(), + model = bannerImage, + contentScale = ContentScale.Crop, + contentDescription = null, + error = rememberAsyncImagePainter( + model = coverImage.extraLarge, + contentScale = ContentScale.Crop, + error = rememberAsyncImagePainter( + model = coverImage.large, + contentScale = ContentScale.Crop + ) + ), + alpha = 1F - state.collapsedFraction + ) + LargeTopAppBar( + navigationIcon = { + IconButton( + modifier = if (isCollapsed) { + Modifier + } else { + Modifier.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75F), CircleShape) + }, + onClick = { + onBack() + } + ) { + Icon( + imageVector = Icons.Default.ArrowBackIosNew, + contentDescription = null + ) + } + }, + title = { + val title by titleFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + ) { + Text( + text = title.preferred(), + softWrap = true, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = if (!isCollapsed) { + LocalTextStyle.current.copy( + shadow = Shadow( + color = MaterialTheme.colorScheme.surface, + offset = Offset(4F, 4F), + blurRadius = 8F + ) + ) + } else { + LocalTextStyle.current + } + ) + title.notPreferred()?.let { + Text( + text = it, + softWrap = true, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = if (!isCollapsed) { + MaterialTheme.typography.labelMedium.copy( + shadow = Shadow( + color = MaterialTheme.colorScheme.surface, + offset = Offset(4F, 4F), + blurRadius = 8F + ) + ) + } else { + MaterialTheme.typography.labelMedium + } + ) + } + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + modifier = Modifier.hazeChild( + state = LocalHaze.current, + style = HazeMaterials.thin( + containerColor = MaterialTheme.colorScheme.surface + ) + ).fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CoverSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CoverSection.kt new file mode 100644 index 0000000..29a9c27 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/CoverSection.kt @@ -0,0 +1,140 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.OndemandVideo +import androidx.compose.material.icons.filled.RssFeed +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.type.MediaFormat +import dev.datlag.aniflow.anilist.type.MediaStatus +import dev.datlag.aniflow.common.shimmerPainter +import dev.datlag.aniflow.common.text +import dev.datlag.tooling.compose.ifTrue +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun CoverSection( + coverImage: Medium.CoverImage, + formatFlow: StateFlow, + episodesFlow: StateFlow, + durationFlow: StateFlow, + statusFlow: StateFlow, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + var coverShadow by remember(coverImage) { mutableStateOf(false) } + + AsyncImage( + modifier = Modifier + .width(140.dp) + .height(200.dp) + .ifTrue(coverShadow) { + shadow( + elevation = 8.dp, + shape = MaterialTheme.shapes.medium, + spotColor = MaterialTheme.colorScheme.primary + ) + }, + model = coverImage.extraLarge, + contentScale = ContentScale.Crop, + contentDescription = null, + placeholder = shimmerPainter(), + error = rememberAsyncImagePainter( + model = coverImage.large, + contentScale = ContentScale.Crop, + placeholder = shimmerPainter(), + error = rememberAsyncImagePainter( + model = coverImage.medium, + contentScale = ContentScale.Crop, + placeholder = shimmerPainter(), + onSuccess = { + coverShadow = true + } + ), + onSuccess = { + coverShadow = true + } + ), + onSuccess = { + coverShadow = true + } + ) + Column( + modifier = Modifier.weight(1F).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + val format by formatFlow.collectAsStateWithLifecycle() + val episodes by episodesFlow.collectAsStateWithLifecycle() + val duration by durationFlow.collectAsStateWithLifecycle() + val status by statusFlow.collectAsStateWithLifecycle() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.OndemandVideo, + contentDescription = null + ) + Text(text = stringResource(format.text())) + } + if (episodes > -1) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null + ) + Text(text = "$episodes Episodes") + } + } + if (duration > -1) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Timelapse, + contentDescription = null + ) + Text(text = "${duration}min / Episode") + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.RssFeed, + contentDescription = null + ) + Text(text = stringResource(status.text())) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/DescriptionSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/DescriptionSection.kt new file mode 100644 index 0000000..d0d910b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/DescriptionSection.kt @@ -0,0 +1,103 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.common.htmlToAnnotatedString +import dev.datlag.tooling.compose.onClick +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.max + +@Composable +fun DescriptionSection( + descriptionFlow: StateFlow, + translatedDescriptionFlow: StateFlow, + modifier: Modifier = Modifier, + onTranslation: (String?) -> Unit +) { + val description by descriptionFlow.collectAsStateWithLifecycle() + + if (!description.isNullOrBlank()) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val translatedDescription by translatedDescriptionFlow.collectAsStateWithLifecycle() + var descriptionExpandable by remember(description) { mutableStateOf(false) } + var descriptionExpanded by remember(description) { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + modifier = Modifier.weight(1F), + text = stringResource(SharedRes.strings.description), + style = MaterialTheme.typography.headlineSmall + ) + TranslateButton(description ?: "") { text -> + onTranslation(text) + } + } + + val animatedLines by animateIntAsState( + targetValue = if (descriptionExpanded) { + Int.MAX_VALUE + } else { + 3 + }, + animationSpec = tween() + ) + + Text( + modifier = Modifier.padding(horizontal = 16.dp).onClick { + descriptionExpanded = !descriptionExpanded + }, + text = (translatedDescription ?: description)!!.htmlToAnnotatedString(), + maxLines = max(animatedLines, 1), + softWrap = true, + overflow = TextOverflow.Ellipsis, + onTextLayout = { result -> + if (!descriptionExpanded) { + descriptionExpandable = result.hasVisualOverflow + } + } + ) + if (descriptionExpandable) { + IconButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + descriptionExpanded = !descriptionExpanded + } + ) { + val icon = if (descriptionExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + } + + Icon( + imageVector = icon, + contentDescription = null + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/GenreSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/GenreSection.kt new file mode 100644 index 0000000..861706f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/GenreSection.kt @@ -0,0 +1,35 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.GenreChip +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun GenreSection( + genreFlow: StateFlow>, + modifier: Modifier = Modifier, +) { + val genres by genreFlow.collectAsStateWithLifecycle() + + if (genres.isNotEmpty()) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + ) { + items(genres.toList()) { genre -> + GenreChip(label = genre) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingSection.kt new file mode 100644 index 0000000..c184697 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/RatingSection.kt @@ -0,0 +1,80 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun RatingSection( + ratedFlow: StateFlow, + popularFlow: StateFlow, + scoreFlow: StateFlow, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + val rated by ratedFlow.collectAsStateWithLifecycle() + val popular by popularFlow.collectAsStateWithLifecycle() + val score by scoreFlow.collectAsStateWithLifecycle() + + rated?.let { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(SharedRes.strings.rated), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = "#${it.rank}", + style = MaterialTheme.typography.displaySmall + ) + } + } + popular?.let { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(SharedRes.strings.popular), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = "#${it.rank}", + style = MaterialTheme.typography.displaySmall + ) + } + } + score?.let { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(SharedRes.strings.score), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = "${it}%", + style = MaterialTheme.typography.displaySmall + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/TrailerSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/TrailerSection.kt new file mode 100644 index 0000000..90026ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/component/TrailerSection.kt @@ -0,0 +1,77 @@ +package dev.datlag.aniflow.ui.navigation.screen.medium.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun TrailerSection( + trailerFlow: StateFlow, + modifier: Modifier = Modifier +) { + val trailer by trailerFlow.collectAsStateWithLifecycle() + + if (trailer != null) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val uriHandler = LocalUriHandler.current + + Text( + modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp), + text = stringResource(SharedRes.strings.trailer), + style = MaterialTheme.typography.headlineSmall + ) + Card( + modifier = Modifier.fillMaxWidth().height(200.dp).padding(16.dp), + onClick = { + uriHandler.openUri(trailer?.videoUrl ?: trailer!!.website) + } + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = trailer?.thumbnail, + contentDescription = null, + contentScale = ContentScale.Crop + ) + if (trailer?.isYoutube == true) { + Image( + modifier = Modifier.size(48.dp), + painter = painterResource(SharedRes.images.youtube), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } else { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.PlayCircleFilled, + contentDescription = null + ) + } + } + } + } + } +} \ No newline at end of file