diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 8ce18705..e3798eed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1,6 +1,8 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -168,6 +170,7 @@ data class DetailRoute(val type: String, val id: String) data class PersonDetailRoute( val personId: Int, val personName: String, + val personPhoto: String? = null, val preferCrew: Boolean = false, ) @@ -387,7 +390,7 @@ fun App() { } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun MainAppContent( onSwitchProfile: () -> Unit = {}, @@ -619,11 +622,12 @@ private fun MainAppContent( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - NavHost( - navController = navController, - startDestination = TabsRoute, - modifier = Modifier.fillMaxSize(), - ) { + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = TabsRoute, + modifier = Modifier.fillMaxSize(), + ) { composable { PlatformBackHandler( enabled = selectedTab != AppScreenTab.Home, @@ -769,6 +773,7 @@ private fun MainAppContent( PersonDetailRoute( personId = tmdbId, personName = person.name, + personPhoto = person.photo, preferCrew = person.role?.let { it.equals("Director", ignoreCase = true) || it.equals("Writer", ignoreCase = true) || @@ -791,6 +796,8 @@ private fun MainAppContent( ) } }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) } @@ -799,6 +806,7 @@ private fun MainAppContent( PersonDetailScreen( personId = route.personId, personName = route.personName, + initialProfilePhoto = route.personPhoto, preferCrew = route.preferCrew, onBack = { navController.popBackStack() }, onOpenMeta = { preview -> @@ -822,6 +830,8 @@ private fun MainAppContent( ) } }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) } @@ -1290,6 +1300,7 @@ private fun MainAppContent( }, ) } + } } NuvioPosterActionSheet( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/CastSharedTransition.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/CastSharedTransition.kt new file mode 100644 index 00000000..fdfaccac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/CastSharedTransition.kt @@ -0,0 +1,3 @@ +package com.nuvio.app.features.details + +internal fun castAvatarSharedTransitionKey(personId: Int): String = "cast-avatar:$personId" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index bd626c2b..2a235634 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.details +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -96,6 +99,7 @@ import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch @Composable +@OptIn(ExperimentalSharedTransitionApi::class) fun MetaDetailsScreen( type: String, id: String, @@ -105,6 +109,8 @@ fun MetaDetailsScreen( onOpenMeta: ((MetaPreview) -> Unit)? = null, onCastClick: ((MetaPerson) -> Unit)? = null, onCompanyClick: ((MetaCompany, String) -> Unit)? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, modifier: Modifier = Modifier, ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() @@ -639,6 +645,8 @@ fun MetaDetailsScreen( onOpenMeta = onOpenMeta, onCastClick = onCastClick, onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) @@ -877,6 +885,7 @@ fun MetaDetailsScreen( } @Composable +@OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( settings: MetaScreenSettingsUiState, meta: MetaDetails, @@ -912,6 +921,8 @@ private fun ConfiguredMetaSections( onOpenMeta: ((MetaPreview) -> Unit)?, onCastClick: ((MetaPerson) -> Unit)?, onCompanyClick: ((MetaCompany, String) -> Unit)?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, ) { val enabledItems = settings.items.filter { it.enabled } @@ -954,7 +965,13 @@ private fun ConfiguredMetaSections( } } MetaScreenSectionKey.CAST -> { - DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick) + DetailCastSection( + cast = meta.cast, + showHeader = showHeader, + onCastClick = onCastClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) } MetaScreenSectionKey.COMMENTS -> { if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 256c1ead..f8f90588 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -1,7 +1,9 @@ package com.nuvio.app.features.details +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -64,12 +66,16 @@ private sealed interface PersonDetailUiState { } @Composable +@OptIn(ExperimentalSharedTransitionApi::class) fun PersonDetailScreen( personId: Int, personName: String, + initialProfilePhoto: String? = null, preferCrew: Boolean = false, onBack: () -> Unit, onOpenMeta: (MetaPreview) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, modifier: Modifier = Modifier, ) { var uiState by remember(personId) { mutableStateOf(PersonDetailUiState.Loading) } @@ -92,25 +98,29 @@ fun PersonDetailScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - Crossfade( - targetState = uiState, - label = "PersonDetailCrossfade", - ) { state -> - when (state) { - is PersonDetailUiState.Loading -> PersonDetailSkeleton(personName = personName) - is PersonDetailUiState.Error -> PersonDetailError( - message = state.message, - onRetry = { - uiState = PersonDetailUiState.Loading - // Retry will be triggered by the LaunchedEffect above if we reset - }, - ) - is PersonDetailUiState.Success -> PersonDetailContent( - person = state.personDetail, - onOpenMeta = onOpenMeta, - ) + when (val state = uiState) { + is PersonDetailUiState.Loading -> PersonDetailSkeleton( + personId = personId, + personName = personName, + profilePhoto = initialProfilePhoto, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + is PersonDetailUiState.Error -> PersonDetailError( + message = state.message, + onRetry = { + uiState = PersonDetailUiState.Loading + // Retry will be triggered by the LaunchedEffect above if we reset + }, + ) + is PersonDetailUiState.Success -> PersonDetailContent( + person = state.personDetail, + onOpenMeta = onOpenMeta, + initialProfilePhoto = initialProfilePhoto, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) } - } // Back button overlaid on top IconButton( @@ -130,9 +140,13 @@ fun PersonDetailScreen( } @Composable +@OptIn(ExperimentalSharedTransitionApi::class) private fun PersonDetailContent( person: PersonDetail, onOpenMeta: (MetaPreview) -> Unit, + initialProfilePhoto: String? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val accentColor = MaterialTheme.colorScheme.primary @@ -223,6 +237,9 @@ private fun PersonDetailContent( HeroSection( person = person, collapseProgress = collapseProgress, + fallbackProfilePhoto = initialProfilePhoto, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) if (popularCredits.isNotEmpty()) { @@ -268,13 +285,30 @@ private const val HERO_COLLAPSE_SCROLL_RANGE = 220f private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56 @Composable +@OptIn(ExperimentalSharedTransitionApi::class) private fun HeroSection( person: PersonDetail, collapseProgress: Float = 0f, + fallbackProfilePhoto: String? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val avatarSize = lerp(140.dp, 72.dp, collapseProgress) val heroScale = 1f - (collapseProgress * 0.12f) val heroAlpha = 1f - (collapseProgress * 0.35f) + val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto + val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = castAvatarSharedTransitionKey(person.tmdbId), + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } else { + Modifier + } Column( modifier = Modifier @@ -291,14 +325,15 @@ private fun HeroSection( // Profile Photo Box( modifier = Modifier + .then(avatarSharedElementModifier) .size(avatarSize) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { - if (!person.profilePhoto.isNullOrBlank()) { + if (!avatarUrl.isNullOrBlank()) { AsyncImage( - model = person.profilePhoto, + model = avatarUrl, contentDescription = person.name, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, @@ -378,7 +413,14 @@ private fun HeroSection( // ─── Loading / Error States ─── @Composable -private fun PersonDetailSkeleton(personName: String) { +@OptIn(ExperimentalSharedTransitionApi::class) +private fun PersonDetailSkeleton( + personId: Int, + personName: String, + profilePhoto: String? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { val accentColor = MaterialTheme.colorScheme.primary val accentGradient = remember(accentColor) { Brush.verticalGradient( @@ -416,12 +458,41 @@ private fun PersonDetailSkeleton(personName: String) { .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = castAvatarSharedTransitionKey(personId), + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } else { + Modifier + } Box( modifier = Modifier + .then(avatarSharedElementModifier) .size(140.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant), - ) + contentAlignment = Alignment.Center, + ) { + if (!profilePhoto.isNullOrBlank()) { + AsyncImage( + model = profilePhoto, + contentDescription = personName, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = personName.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Spacer(modifier = Modifier.height(16.dp)) Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 3515b1ee..d2b315b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -1,5 +1,8 @@ package com.nuvio.app.features.details.components +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -27,13 +30,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaPerson +import com.nuvio.app.features.details.castAvatarSharedTransitionKey @Composable +@OptIn(ExperimentalSharedTransitionApi::class) fun DetailCastSection( cast: List, modifier: Modifier = Modifier, showHeader: Boolean = true, onCastClick: ((MetaPerson) -> Unit)? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { if (cast.isEmpty()) return @@ -55,6 +62,8 @@ fun DetailCastSection( CastItem( person = person, sizing = sizing, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, onClick = if (onCastClick != null && person.tmdbId != null && person.tmdbId > 0) { { onCastClick(person) } } else { @@ -68,12 +77,33 @@ fun DetailCastSection( } @Composable +@OptIn(ExperimentalSharedTransitionApi::class) private fun CastItem( person: MetaPerson, modifier: Modifier = Modifier, sizing: CastSectionSizing, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, onClick: (() -> Unit)? = null, ) { + val avatarSharedElementModifier = if ( + sharedTransitionScope != null && + animatedVisibilityScope != null && + person.tmdbId != null && + person.tmdbId > 0 + ) { + with(sharedTransitionScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = castAvatarSharedTransitionKey(person.tmdbId), + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } else { + Modifier + } + Column( modifier = modifier .width(sizing.itemWidth) @@ -83,6 +113,7 @@ private fun CastItem( ) { Box( modifier = Modifier + .then(avatarSharedElementModifier) .size(sizing.avatarSize) .clip(CircleShape) .background( diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 134ad5cf..59593c57 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,2 +1,2 @@ -CURRENT_PROJECT_VERSION=18 -MARKETING_VERSION=0.1.0-alpha18 \ No newline at end of file +CURRENT_PROJECT_VERSION=19 +MARKETING_VERSION=0.1.0-alpha19 \ No newline at end of file