diff --git a/common-ui-compose/build.gradle b/common-ui-compose/build.gradle index bf26fa39c7..ca153f0c5c 100644 --- a/common-ui-compose/build.gradle +++ b/common-ui-compose/build.gradle @@ -45,6 +45,7 @@ android { dependencies { implementation project(':base-android') + implementation project(':data') api project(':common-imageloading') implementation Libs.AndroidX.coreKtx @@ -62,4 +63,6 @@ dependencies { implementation Libs.Mdc.material implementation Libs.Kotlin.stdlib + + implementation Libs.Accompanist.coil } \ No newline at end of file diff --git a/common-ui-compose/src/main/java/app/tivi/common/compose/Carousel.kt b/common-ui-compose/src/main/java/app/tivi/common/compose/Carousel.kt new file mode 100644 index 0000000000..8902da29bc --- /dev/null +++ b/common-ui-compose/src/main/java/app/tivi/common/compose/Carousel.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.common.compose + +import androidx.compose.foundation.layout.InnerPadding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyRowFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun Carousel( + items: List, + modifier: Modifier = Modifier, + contentPadding: InnerPadding = InnerPadding(0.dp), + itemSpacing: Dp = 0.dp, + verticalGravity: Alignment.Vertical = Alignment.Top, + itemContent: @Composable LazyItemScope.(T, InnerPadding) -> Unit +) { + val halfSpacing = itemSpacing / 2 + val spacingContent = InnerPadding(halfSpacing, 0.dp, halfSpacing, 0.dp) + + LazyRowFor( + items = items, + modifier = modifier, + contentPadding = contentPadding.copy( + start = (contentPadding.start - halfSpacing).coerceAtLeast(0.dp), + end = (contentPadding.end - halfSpacing).coerceAtLeast(0.dp) + ), + verticalGravity = verticalGravity, + itemContent = { item -> itemContent(item, spacingContent) } + ) +} diff --git a/common-ui-compose/src/main/java/app/tivi/common/compose/PosterCard.kt b/common-ui-compose/src/main/java/app/tivi/common/compose/PosterCard.kt new file mode 100644 index 0000000000..8839855fc3 --- /dev/null +++ b/common-ui-compose/src/main/java/app/tivi/common/compose/PosterCard.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.common.compose + +import androidx.compose.foundation.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Stack +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.EmphasisAmbient +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideEmphasis +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.tivi.data.entities.TiviShow +import app.tivi.data.entities.TmdbImageEntity +import dev.chrisbanes.accompanist.coil.CoilImageWithCrossfade + +@Composable +fun PosterCard( + show: TiviShow, + poster: TmdbImageEntity? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Card(modifier = modifier) { + Stack( + modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ) { + // TODO: remove text if the image has loaded (and animated in). + // https://github.com/chrisbanes/accompanist/issues/76 + ProvideEmphasis(EmphasisAmbient.current.medium) { + Text( + text = show.title ?: "No title", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(4.dp).gravity(Alignment.CenterStart) + ) + } + if (poster != null) { + CoilImageWithCrossfade( + data = poster, + modifier = Modifier.matchParentSize() + ) + } + } + } +} diff --git a/data/src/main/java/app/tivi/data/daos/PopularDao.kt b/data/src/main/java/app/tivi/data/daos/PopularDao.kt index 2d79027973..6bcc886d75 100644 --- a/data/src/main/java/app/tivi/data/daos/PopularDao.kt +++ b/data/src/main/java/app/tivi/data/daos/PopularDao.kt @@ -31,8 +31,8 @@ abstract class PopularDao : PaginatedEntryDao> @Transaction - @Query("SELECT * FROM popular_shows ORDER BY page, page_order") - abstract fun entriesObservable(): Flow> + @Query("SELECT * FROM popular_shows ORDER BY page, page_order LIMIT :count OFFSET :offset") + abstract fun entriesObservable(count: Int, offset: Int): Flow> @Transaction @Query("SELECT * FROM popular_shows ORDER BY page, page_order") diff --git a/domain/src/main/java/app/tivi/domain/observers/ObservePopularShows.kt b/domain/src/main/java/app/tivi/domain/observers/ObservePopularShows.kt index 66e6b9ddd6..b3de0d1037 100644 --- a/domain/src/main/java/app/tivi/domain/observers/ObservePopularShows.kt +++ b/domain/src/main/java/app/tivi/domain/observers/ObservePopularShows.kt @@ -24,9 +24,13 @@ import javax.inject.Inject class ObservePopularShows @Inject constructor( private val popularShowsRepository: PopularDao -) : SubjectInteractor>() { +) : SubjectInteractor>() { - override fun createObservable(params: Unit): Flow> { - return popularShowsRepository.entriesObservable() + override fun createObservable( + params: ObservePopularShows.Params + ): Flow> { + return popularShowsRepository.entriesObservable(params.count, 0) } + + data class Params(val count: Int = 20) } diff --git a/domain/src/main/java/app/tivi/domain/observers/ObserveRecommendedShows.kt b/domain/src/main/java/app/tivi/domain/observers/ObserveRecommendedShows.kt index c0ef2e89fb..d774ab8abb 100644 --- a/domain/src/main/java/app/tivi/domain/observers/ObserveRecommendedShows.kt +++ b/domain/src/main/java/app/tivi/domain/observers/ObserveRecommendedShows.kt @@ -24,9 +24,13 @@ import javax.inject.Inject class ObserveRecommendedShows @Inject constructor( private val recommendedDao: RecommendedDao -) : SubjectInteractor>() { +) : SubjectInteractor>() { - override fun createObservable(params: Unit): Flow> { - return recommendedDao.entriesObservable(20, 0) + override fun createObservable( + params: ObserveRecommendedShows.Params + ): Flow> { + return recommendedDao.entriesObservable(params.count, 0) } + + data class Params(val count: Int = 20) } diff --git a/ui-account/src/main/java/app/tivi/account/AccountUiViewState.kt b/ui-account/src/main/java/app/tivi/account/AccountUiViewState.kt index 1a7efcb8b1..eca55a8b5a 100644 --- a/ui-account/src/main/java/app/tivi/account/AccountUiViewState.kt +++ b/ui-account/src/main/java/app/tivi/account/AccountUiViewState.kt @@ -16,9 +16,11 @@ package app.tivi.account +import androidx.compose.runtime.Immutable import app.tivi.data.entities.TraktUser import app.tivi.trakt.TraktAuthState +@Immutable data class AccountUiViewState( val user: TraktUser? = null, val authState: TraktAuthState = TraktAuthState.LOGGED_OUT diff --git a/ui-discover/build.gradle b/ui-discover/build.gradle index 3ff1c8666a..e8dd732a6c 100644 --- a/ui-discover/build.gradle +++ b/ui-discover/build.gradle @@ -42,7 +42,12 @@ android { } buildFeatures { - dataBinding true + compose true + } + + composeOptions { + kotlinCompilerVersion Libs.Kotlin.version + kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version } } @@ -51,24 +56,30 @@ dependencies { implementation project(':base-android') implementation project(':domain') implementation project(':common-ui-view') - implementation project(':common-epoxy') - implementation project(':common-layouts') - implementation project(':common-imageloading') - implementation project(':common-databinding') + implementation project(':common-ui-compose') implementation Libs.AndroidX.Lifecycle.livedata implementation Libs.AndroidX.Lifecycle.viewmodel implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.recyclerview - implementation Libs.AndroidX.swiperefresh - implementation Libs.AndroidX.constraintlayout implementation Libs.AndroidX.coreKtx implementation Libs.AndroidX.Fragment.fragment implementation Libs.AndroidX.Fragment.fragmentKtx implementation Libs.AndroidX.Navigation.fragment + implementation Libs.AndroidX.Compose.runtime + implementation Libs.AndroidX.Compose.foundation + implementation Libs.AndroidX.Compose.ui + implementation Libs.AndroidX.Compose.layout + implementation Libs.AndroidX.Compose.material + implementation Libs.AndroidX.Compose.animation + implementation Libs.AndroidX.Compose.tooling + implementation Libs.AndroidX.Compose.livedata + implementation Libs.Mdc.material + implementation Libs.Mdc.composeThemeAdapter + + implementation Libs.Accompanist.coil implementation Libs.Hilt.library kapt Libs.Hilt.compiler diff --git a/ui-discover/src/main/java/app/tivi/home/discover/Discover.kt b/ui-discover/src/main/java/app/tivi/home/discover/Discover.kt new file mode 100644 index 0000000000..700376df20 --- /dev/null +++ b/ui-discover/src/main/java/app/tivi/home/discover/Discover.kt @@ -0,0 +1,390 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.home.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Icon +import androidx.compose.foundation.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.InnerPadding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope.alignWithSiblings +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Stack +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredHeight +import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.lazy.ExperimentalLazyDsl +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.FirstBaseline +import androidx.compose.material.EmphasisAmbient +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideEmphasis +import androidx.compose.material.Surface +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.platform.DensityAmbient +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.ui.tooling.preview.Preview +import app.tivi.common.compose.AutoSizedCircularProgressIndicator +import app.tivi.common.compose.Carousel +import app.tivi.common.compose.IconResource +import app.tivi.common.compose.PosterCard +import app.tivi.common.compose.onSizeChanged +import app.tivi.common.compose.rememberMutableState +import app.tivi.common.compose.statusBarsPadding +import app.tivi.data.entities.Episode +import app.tivi.data.entities.Season +import app.tivi.data.entities.TiviShow +import app.tivi.data.entities.TmdbImageEntity +import app.tivi.data.entities.TraktUser +import app.tivi.data.resultentities.EntryWithShow +import app.tivi.trakt.TraktAuthState +import dev.chrisbanes.accompanist.coil.CoilImage + +@OptIn(ExperimentalLazyDsl::class) +@Composable +fun Discover( + state: DiscoverViewState, + actioner: (DiscoverAction) -> Unit +) { + Surface(Modifier.fillMaxSize()) { + Stack(Modifier.fillMaxSize()) { + var appBarHeight by rememberMutableState { 0 } + + LazyColumn(Modifier.fillMaxSize()) { + item { + val height = with(DensityAmbient.current) { appBarHeight.toDp() } + 16.dp + Spacer(Modifier.preferredHeight(height)) + } + + state.nextEpisodeWithShowToWatched?.let { nextEpisodeToWatch -> + item { + Header(title = stringResource(R.string.discover_keep_watching_title)) + } + item { + NextEpisodeToWatch( + show = nextEpisodeToWatch.show, + poster = nextEpisodeToWatch.poster, + season = nextEpisodeToWatch.season, + episode = nextEpisodeToWatch.episode, + modifier = Modifier.fillMaxWidth().clickable { + actioner( + OpenShowDetails( + showId = nextEpisodeToWatch.show.id, + episodeId = nextEpisodeToWatch.episode.id + ) + ) + } + ) + } + + item { Spacer(Modifier.preferredHeight(16.dp)) } + } + + item { + CarouselWithHeader( + items = state.trendingItems, + title = stringResource(R.string.discover_trending_title), + refreshing = state.trendingRefreshing, + onItemClick = { actioner(OpenShowDetails(it.id)) }, + onMoreClick = { actioner(OpenTrendingShows) } + ) + } + + item { + CarouselWithHeader( + items = state.recommendedItems, + title = stringResource(R.string.discover_recommended_title), + refreshing = state.recommendedRefreshing, + onItemClick = { actioner(OpenShowDetails(it.id)) }, + onMoreClick = { actioner(OpenRecommendedShows) } + ) + } + + item { + CarouselWithHeader( + items = state.popularItems, + title = stringResource(R.string.discover_popular_title), + refreshing = state.popularRefreshing, + onItemClick = { actioner(OpenShowDetails(it.id)) }, + onMoreClick = { actioner(OpenPopularShows) } + ) + } + + item { Spacer(Modifier.preferredHeight(16.dp)) } + } + + DiscoverAppBar( + loggedIn = state.authState == TraktAuthState.LOGGED_IN, + user = state.user, + refreshing = state.refreshing, + onRefreshActionClick = { actioner(RefreshAction) }, + onUserActionClick = { actioner(OpenUserDetails) }, + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { appBarHeight = it.height } + ) + } + } +} + +@Composable +private fun NextEpisodeToWatch( + show: TiviShow, + poster: TmdbImageEntity?, + season: Season, + episode: Episode, + modifier: Modifier = Modifier, +) { + Surface(modifier) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + if (poster != null) { + PosterCard( + show = show, + poster = poster, + modifier = Modifier.preferredWidth(64.dp).aspectRatio(2 / 3f) + ) + + Spacer(Modifier.preferredWidth(16.dp)) + } + + Column(Modifier.gravity(Alignment.CenterVertically)) { + val textCreator = DiscoverTextCreatorAmbient.current + ProvideEmphasis(EmphasisAmbient.current.disabled) { + Text( + text = textCreator.seasonEpisodeTitleText(season, episode), + style = MaterialTheme.typography.caption + ) + } + + Spacer(Modifier.preferredHeight(4.dp)) + + ProvideEmphasis(EmphasisAmbient.current.high) { + Text( + text = episode.title + ?: stringResource(R.string.episode_title_fallback, episode.number!!), + style = MaterialTheme.typography.body1 + ) + } + } + } + } +} + +@Composable +private fun > CarouselWithHeader( + items: List, + title: String, + refreshing: Boolean, + onItemClick: (TiviShow) -> Unit, + onMoreClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier) { + if (refreshing || items.isNotEmpty()) { + Spacer(Modifier.preferredHeight(16.dp)) + + Header( + title = title, + loading = refreshing, + action = { + TextButton( + onClick = onMoreClick, + contentColor = MaterialTheme.colors.secondary, + modifier = Modifier.alignWithSiblings(FirstBaseline) + ) { + Text(text = stringResource(R.string.header_more)) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + if (items.isNotEmpty()) { + EntryShowCarousel( + items = items, + onItemClick = onItemClick, + modifier = Modifier.preferredHeight(192.dp).fillMaxWidth() + ) + } else { + // TODO empty state + } + } +} + +@Composable +private fun > EntryShowCarousel( + items: List, + onItemClick: (TiviShow) -> Unit, + modifier: Modifier = Modifier +) { + Carousel( + items = items, + contentPadding = InnerPadding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + itemSpacing = 4.dp, + modifier = modifier + ) { item, padding -> + PosterCard( + show = item.show, + poster = item.poster, + onClick = { onItemClick(item.show) }, + modifier = Modifier + .padding(padding) + .fillParentMaxHeight() + .aspectRatio(2 / 3f) + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun Header( + title: String, + modifier: Modifier = Modifier, + loading: Boolean = false, + action: (@Composable () -> Unit)? = null +) { + Row(modifier) { + Spacer(Modifier.preferredWidth(16.dp)) + + ProvideEmphasis(EmphasisAmbient.current.high) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + modifier = Modifier + .gravity(Alignment.CenterVertically) + .padding(vertical = 8.dp) + .weight(1f, true) + ) + } + + AnimatedVisibility(visible = loading) { + AutoSizedCircularProgressIndicator( + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(8.dp).preferredSize(16.dp) + ) + } + + if (action != null) action() + + Spacer(Modifier.preferredWidth(16.dp)) + } +} + +private const val TranslucentAppBarAlpha = 0.93f + +@Composable +private fun DiscoverAppBar( + loggedIn: Boolean, + user: TraktUser?, + refreshing: Boolean, + onRefreshActionClick: () -> Unit, + onUserActionClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = MaterialTheme.colors.surface.copy(alpha = TranslucentAppBarAlpha), + contentColor = MaterialTheme.colors.onSurface, + elevation = 4.dp, + modifier = modifier + ) { + Row( + modifier = Modifier + .statusBarsPadding() + .preferredHeight(56.dp) + .padding(start = 16.dp, end = 4.dp) + ) { + ProvideEmphasis(EmphasisAmbient.current.high) { + Text( + text = stringResource(R.string.discover_title), + style = MaterialTheme.typography.h6, + modifier = Modifier.weight(1f, fill = true) + .gravity(Alignment.CenterVertically) + ) + } + + ProvideEmphasis(EmphasisAmbient.current.medium) { + IconButton( + onClick = onRefreshActionClick, + enabled = !refreshing, + modifier = Modifier.gravity(Alignment.CenterVertically) + ) { + if (refreshing) { + AutoSizedCircularProgressIndicator(Modifier.preferredSize(20.dp)) + } else { + Icon(Icons.Default.Refresh) + } + } + + IconButton( + onClick = onUserActionClick, + modifier = Modifier.gravity(Alignment.CenterVertically) + ) { + when { + loggedIn && user?.avatarUrl != null -> { + CoilImage( + data = user.avatarUrl!!, + modifier = Modifier.preferredSize(32.dp).clip(CircleShape) + ) + } + loggedIn -> IconResource(R.drawable.ic_person) + else -> IconResource(R.drawable.ic_person_outline) + } + } + } + } + } +} + +@Preview +@Composable +private fun PreviewDiscoverAppBar() { + DiscoverAppBar( + loggedIn = false, + user = null, + refreshing = false, + onUserActionClick = {}, + onRefreshActionClick = {} + ) +} + +@Preview +@Composable +private fun PreviewHeader() { + Surface(Modifier.fillMaxWidth()) { + Header( + title = "Being watched now", + loading = true + ) + } +} diff --git a/ui-discover/src/main/java/app/tivi/home/discover/EpoxyDataBindingConfig.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverActions.kt similarity index 59% rename from ui-discover/src/main/java/app/tivi/home/discover/EpoxyDataBindingConfig.kt rename to ui-discover/src/main/java/app/tivi/home/discover/DiscoverActions.kt index 2689944abe..73140c77a9 100644 --- a/ui-discover/src/main/java/app/tivi/home/discover/EpoxyDataBindingConfig.kt +++ b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverActions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 Google LLC + * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,11 @@ package app.tivi.home.discover -import com.airbnb.epoxy.EpoxyDataBindingPattern - -@EpoxyDataBindingPattern(rClass = R::class, layoutPrefix = "view_holder") -internal object EpoxyDataBindingConfig +sealed class DiscoverAction +object RefreshAction : DiscoverAction() +object LoginAction : DiscoverAction() +object OpenUserDetails : DiscoverAction() +data class OpenShowDetails(val showId: Long, val episodeId: Long? = null) : DiscoverAction() +object OpenTrendingShows : DiscoverAction() +object OpenPopularShows : DiscoverAction() +object OpenRecommendedShows : DiscoverAction() diff --git a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverEpoxyController.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverEpoxyController.kt deleted file mode 100644 index 5ef2502c93..0000000000 --- a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverEpoxyController.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright 2017 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.discover - -import android.content.Context -import app.tivi.common.epoxy.TotalSpanOverride -import app.tivi.common.epoxy.tiviCarousel -import app.tivi.common.epoxy.withModelsFrom -import app.tivi.common.layouts.PosterCardItemBindingModel_ -import app.tivi.common.layouts.emptyState -import app.tivi.common.layouts.header -import app.tivi.common.layouts.vertSpacerNormal -import app.tivi.data.Entry -import app.tivi.data.resultentities.EntryWithShow -import app.tivi.extensions.observable -import com.airbnb.epoxy.Carousel -import com.airbnb.epoxy.EpoxyController -import dagger.hilt.android.qualifiers.ActivityContext -import javax.inject.Inject - -internal class DiscoverEpoxyController @Inject constructor( - @ActivityContext private val context: Context, - private val textCreator: DiscoverTextCreator -) : EpoxyController() { - var callbacks: Callbacks? by observable(null, ::requestModelBuild) - var state: DiscoverViewState by observable(DiscoverViewState(), ::requestModelBuild) - - interface Callbacks { - fun onTrendingHeaderClicked() - fun onPopularHeaderClicked() - fun onRecommendedHeaderClicked() - fun onNextEpisodeToWatchClicked() - fun onItemClicked(viewHolderId: Long, item: EntryWithShow) - } - - override fun buildModels() { - val trendingShows = state.trendingItems - val popularShows = state.popularItems - val recommendedShows = state.recommendedItems - - vertSpacerNormal { - id("top_spacer") - } - - state.nextEpisodeWithShowToWatched?.also { nextEpisodeToWatch -> - header { - id("keep_watching_header") - title(R.string.discover_keep_watching_title) - spanSizeOverride(TotalSpanOverride) - } - discoverNextShowEpisodeToWatch { - id("keep_watching_${nextEpisodeToWatch.episode.id}") - spanSizeOverride(TotalSpanOverride) - episode(nextEpisodeToWatch.episode) - season(nextEpisodeToWatch.season) - tiviShow(nextEpisodeToWatch.show) - posterImage(nextEpisodeToWatch.poster) - textCreator(textCreator) - clickListener { _ -> callbacks?.onNextEpisodeToWatchClicked() } - } - } - - if (modelCountBuiltSoFar > 1) { - vertSpacerNormal { - id("trending_header_spacer") - } - } - header { - id("trending_header") - title(R.string.discover_trending_title) - showProgress(state.trendingRefreshing) - spanSizeOverride(TotalSpanOverride) - buttonClickListener { _ -> callbacks?.onTrendingHeaderClicked() } - } - if (trendingShows.isNotEmpty()) { - tiviCarousel { - id("trending_carousel") - itemWidth(context.resources.getDimensionPixelSize(R.dimen.discover_carousel_item_width)) - hasFixedSize(true) - - val vert = context.resources.getDimensionPixelSize(R.dimen.spacing_small) - val horiz = context.resources.getDimensionPixelSize(R.dimen.spacing_normal) - val itemSpacing = context.resources.getDimensionPixelSize(R.dimen.spacing_micro) - padding(Carousel.Padding(horiz, vert, horiz, vert, itemSpacing)) - - withModelsFrom(trendingShows) { item -> - PosterCardItemBindingModel_().apply { - id(item.generateStableId()) - tiviShow(item.show) - posterImage(item.poster) - transitionName("trending_${item.show.homepage}") - clickListener { model, _, _, _ -> - callbacks?.onItemClicked(model.id(), item) - } - } - } - } - } else { - emptyState { - id("trending_placeholder") - spanSizeOverride(TotalSpanOverride) - } - } - - if (recommendedShows.isNotEmpty()) { - vertSpacerNormal { - id("recommended_header_spacer") - } - header { - id("recommended_header") - title(R.string.discover_recommended_title) - showProgress(state.recommendedRefreshing) - spanSizeOverride(TotalSpanOverride) - buttonClickListener { _ -> callbacks?.onRecommendedHeaderClicked() } - } - tiviCarousel { - id("recommended_carousel") - itemWidth(context.resources.getDimensionPixelSize(R.dimen.discover_carousel_item_width)) - hasFixedSize(true) - - val vert = context.resources.getDimensionPixelSize(R.dimen.spacing_small) - val horiz = context.resources.getDimensionPixelSize(R.dimen.spacing_normal) - val itemSpacing = context.resources.getDimensionPixelSize(R.dimen.spacing_micro) - padding(Carousel.Padding(horiz, vert, horiz, vert, itemSpacing)) - - withModelsFrom(recommendedShows) { item -> - PosterCardItemBindingModel_().apply { - id(item.generateStableId()) - tiviShow(item.show) - posterImage(item.poster) - transitionName("recommended_${item.show.homepage}") - clickListener { model, _, _, _ -> - callbacks?.onItemClicked(model.id(), item) - } - } - } - } - } - - vertSpacerNormal { - id("popular_header_spacer") - } - header { - id("popular_header") - title(R.string.discover_popular_title) - showProgress(state.popularRefreshing) - spanSizeOverride(TotalSpanOverride) - buttonClickListener { _ -> callbacks?.onPopularHeaderClicked() } - } - if (popularShows.isNotEmpty()) { - tiviCarousel { - id("popular_carousel") - itemWidth(context.resources.getDimensionPixelSize(R.dimen.discover_carousel_item_width)) - hasFixedSize(true) - - val vert = context.resources.getDimensionPixelSize(R.dimen.spacing_small) - val horiz = context.resources.getDimensionPixelSize(R.dimen.spacing_normal) - val itemSpacing = context.resources.getDimensionPixelSize(R.dimen.spacing_micro) - padding(Carousel.Padding(horiz, vert, horiz, vert, itemSpacing)) - - withModelsFrom(popularShows) { item -> - PosterCardItemBindingModel_().apply { - id(item.generateStableId()) - posterImage(item.poster) - tiviShow(item.show) - transitionName("popular_${item.show.homepage}") - clickListener { model, _, _, _ -> - callbacks?.onItemClicked(model.id(), item) - } - } - } - } - } else { - emptyState { - id("popular_placeholder") - spanSizeOverride(TotalSpanOverride) - } - } - - vertSpacerNormal { - id("bottom_spacer") - } - } - - fun clear() { - callbacks = null - } -} diff --git a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverFragment.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverFragment.kt index c6e742e160..cfa97dcaa4 100644 --- a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverFragment.kt +++ b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverFragment.kt @@ -18,173 +18,95 @@ package app.tivi.home.discover import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.Providers +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView import androidx.core.net.toUri -import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import app.tivi.FragmentWithBinding -import app.tivi.common.imageloading.loadImageUrl -import app.tivi.data.Entry -import app.tivi.data.resultentities.EntryWithShow -import app.tivi.extensions.doOnSizeChange +import app.tivi.common.compose.LogCompositions +import app.tivi.common.compose.ProvideDisplayInsets +import app.tivi.common.compose.TiviDateFormatterAmbient import app.tivi.extensions.scheduleStartPostponedTransitions -import app.tivi.extensions.toActivityNavigatorExtras -import app.tivi.extensions.toFragmentNavigatorExtras -import app.tivi.home.discover.databinding.FragmentDiscoverBinding -import app.tivi.ui.AuthStateMenuItemBinder -import app.tivi.ui.SpacingItemDecorator -import app.tivi.ui.authStateToolbarMenuBinder -import app.tivi.ui.createSharedElementHelperForItemId -import app.tivi.ui.createSharedElementHelperForItems -import app.tivi.ui.transitions.GridToGridTransitioner +import app.tivi.util.TiviDateFormatter +import com.google.android.material.composethemeadapter.MdcTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class DiscoverFragment : FragmentWithBinding() { - private val viewModel: DiscoverViewModel by viewModels() +class DiscoverFragment : Fragment() { + @Inject internal lateinit var tiviDateFormatter: TiviDateFormatter + @Inject internal lateinit var textCreator: DiscoverTextCreator - @Inject internal lateinit var controller: DiscoverEpoxyController + private val viewModel: DiscoverViewModel by viewModels() - private var authStateMenuItemBinder: AuthStateMenuItemBinder? = null + private val pendingActions = Channel(Channel.BUFFERED) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - GridToGridTransitioner.setupFirstFragment(this) - } - - override fun createBinding( + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): FragmentDiscoverBinding { - return FragmentDiscoverBinding.inflate(inflater, container, false) - } - - override fun onViewCreated(binding: FragmentDiscoverBinding, savedInstanceState: Bundle?) { - // Disable transition for now due to https://issuetracker.google.com/129035555 - // postponeEnterTransitionWithTimeout() - - binding.summaryRv.apply { - setController(controller) - addItemDecoration(SpacingItemDecorator(paddingLeft)) - } - - binding.followedAppBar.doOnSizeChange { - binding.summaryRv.updatePadding(top = it.height) - binding.summarySwipeRefresh.setProgressViewOffset( - true, 0, - it.height + binding.summarySwipeRefresh.progressCircleDiameter / 2 - ) - true - } - - authStateMenuItemBinder = authStateToolbarMenuBinder( - binding.discoverToolbar, - R.id.home_menu_user_avatar, - R.id.home_menu_user_login - ) { menuItem, url -> menuItem.loadImageUrl(requireContext(), url) } - - binding.discoverToolbar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.home_menu_user_login, R.id.home_menu_user_avatar -> { - findNavController().navigate(R.id.navigation_account) - true + ): View? = ComposeView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setContent { + MdcTheme { + LogCompositions("MdcTheme") + + Providers( + TiviDateFormatterAmbient provides tiviDateFormatter, + DiscoverTextCreatorAmbient provides textCreator + ) { + ProvideDisplayInsets { + LogCompositions("ProvideInsets") + + val viewState by viewModel.liveData.observeAsState() + if (viewState != null) { + Discover( + state = viewState!!, + actioner = { pendingActions.offer(it) } + ) + } + } } - else -> false } } + } - controller.callbacks = object : DiscoverEpoxyController.Callbacks { - override fun onTrendingHeaderClicked() { - with(viewModel.currentState()) { - val extras = binding.summaryRv.createSharedElementHelperForItems(trendingItems) - - findNavController().navigate( - R.id.navigation_trending, - null, - null, - extras.toFragmentNavigatorExtras() - ) - } - } - - override fun onPopularHeaderClicked() { - with(viewModel.currentState()) { - val extras = binding.summaryRv.createSharedElementHelperForItems(popularItems) - - findNavController().navigate( - R.id.navigation_popular, - null, - null, - extras.toFragmentNavigatorExtras() - ) - } - } - - override fun onRecommendedHeaderClicked() { - with(viewModel.currentState()) { - val extras = binding.summaryRv.createSharedElementHelperForItems(recommendedItems) - - findNavController().navigate( - R.id.navigation_recommended, - null, - null, - extras.toFragmentNavigatorExtras() - ) - } - } - - override fun onItemClicked(viewHolderId: Long, item: EntryWithShow) { - val elements = binding.summaryRv.createSharedElementHelperForItemId(viewHolderId, "poster") { - it.findViewById(R.id.show_poster) - } - findNavController().navigate( - "app.tivi://show/${item.show.id}".toUri(), - null, - elements.toActivityNavigatorExtras(requireActivity()) - ) - } - - override fun onNextEpisodeToWatchClicked() { - with(viewModel.currentState()) { - checkNotNull(nextEpisodeWithShowToWatched) - val show = nextEpisodeWithShowToWatched.show - val episode = nextEpisodeWithShowToWatched.episode - findNavController().navigate( - "app.tivi://show/${show.id}/episode/${episode.id}".toUri() - ) + override fun onStart() { + super.onStart() + // TODO move this once we know how to handle transitions in Compose + scheduleStartPostponedTransitions() + + lifecycleScope.launch { + pendingActions.consumeAsFlow().collect { action -> + when (action) { + LoginAction, + OpenUserDetails -> findNavController().navigate(R.id.navigation_account) + is OpenShowDetails -> { + var uri = "app.tivi://show/${action.showId}" + if (action.episodeId != null) { + uri += "/episode/${action.episodeId}" + } + findNavController().navigate(uri.toUri()) + } + OpenTrendingShows -> findNavController().navigate(R.id.navigation_trending) + OpenPopularShows -> findNavController().navigate(R.id.navigation_popular) + OpenRecommendedShows -> findNavController().navigate(R.id.navigation_recommended) + else -> viewModel.submitAction(action) } } } - - binding.summarySwipeRefresh.setOnRefreshListener { - viewModel.refresh() - binding.summarySwipeRefresh.postOnAnimation { - binding.summarySwipeRefresh.isRefreshing = false - } - } - - viewModel.liveData.observe(viewLifecycleOwner, ::render) - } - - private fun render(state: DiscoverViewState) { - val binding = requireBinding() - if (binding.state == null) { - // First time we've had state, start any postponed transitions - scheduleStartPostponedTransitions() - } - - authStateMenuItemBinder?.bind(state.authState, state.user) - - binding.state = state - controller.state = state - } - - override fun onDestroyView() { - super.onDestroyView() - controller.clear() - authStateMenuItemBinder = null } } diff --git a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverTextCreator.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverTextCreator.kt index e3572bb84f..f6954bbcb6 100644 --- a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverTextCreator.kt +++ b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverTextCreator.kt @@ -17,12 +17,15 @@ package app.tivi.home.discover import android.content.Context +import androidx.compose.runtime.staticAmbientOf import app.tivi.data.entities.Episode import app.tivi.data.entities.Season import dagger.hilt.android.qualifiers.ActivityContext import javax.inject.Inject -internal class DiscoverTextCreator @Inject constructor( +val DiscoverTextCreatorAmbient = staticAmbientOf() + +class DiscoverTextCreator @Inject constructor( @ActivityContext private val context: Context ) { fun seasonEpisodeTitleText(season: Season, episode: Episode): String { diff --git a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewModel.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewModel.kt index 8013017282..76292f2c27 100644 --- a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewModel.kt +++ b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewModel.kt @@ -18,7 +18,6 @@ package app.tivi.home.discover import androidx.hilt.lifecycle.ViewModelInject import androidx.lifecycle.viewModelScope -import app.tivi.AppNavigator import app.tivi.ReduxViewModel import app.tivi.domain.interactors.UpdatePopularShows import app.tivi.domain.interactors.UpdateRecommendedShows @@ -33,10 +32,12 @@ import app.tivi.domain.observers.ObserveUserDetails import app.tivi.trakt.TraktAuthState import app.tivi.util.ObservableLoadingCounter import app.tivi.util.collectInto +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Provider internal class DiscoverViewModel @ViewModelInject constructor( private val updatePopularShows: UpdatePopularShows, @@ -47,8 +48,7 @@ internal class DiscoverViewModel @ViewModelInject constructor( observeRecommendedShows: ObserveRecommendedShows, observeNextShowEpisodeToWatch: ObserveNextShowEpisodeToWatch, observeTraktAuthState: ObserveTraktAuthState, - observeUserDetails: ObserveUserDetails, - private val appNavigator: Provider + observeUserDetails: ObserveUserDetails ) : ReduxViewModel( DiscoverViewState() ) { @@ -56,6 +56,8 @@ internal class DiscoverViewModel @ViewModelInject constructor( private val popularLoadingState = ObservableLoadingCounter() private val recommendedLoadingState = ObservableLoadingCounter() + private val pendingActions = Channel(Channel.BUFFERED) + init { viewModelScope.launch { trendingLoadingState.observable @@ -77,21 +79,21 @@ internal class DiscoverViewModel @ViewModelInject constructor( .distinctUntilChanged() .collectAndSetState { copy(trendingItems = it) } } - observeTrendingShows(ObserveTrendingShows.Params(15)) + observeTrendingShows(ObserveTrendingShows.Params(10)) viewModelScope.launch { observePopularShows.observe() .distinctUntilChanged() .collectAndSetState { copy(popularItems = it) } } - observePopularShows() + observePopularShows(ObservePopularShows.Params(10)) viewModelScope.launch { observeRecommendedShows.observe() .distinctUntilChanged() .collectAndSetState { copy(recommendedItems = it) } } - observeRecommendedShows() + observeRecommendedShows(ObserveRecommendedShows.Params(10)) viewModelScope.launch { observeNextShowEpisodeToWatch.observe() @@ -113,11 +115,17 @@ internal class DiscoverViewModel @ViewModelInject constructor( } observeUserDetails(ObserveUserDetails.Params("me")) + viewModelScope.launch { + pendingActions.consumeAsFlow().collect { action -> + when (action) { + RefreshAction -> refresh(true) + } + } + } + refresh(false) } - fun refresh() = refresh(true) - private fun refresh(fromUser: Boolean) { viewModelScope.launch { updatePopularShows(UpdatePopularShows.Params(UpdatePopularShows.Page.REFRESH, fromUser)) @@ -132,4 +140,10 @@ internal class DiscoverViewModel @ViewModelInject constructor( .collectInto(recommendedLoadingState) } } + + fun submitAction(action: DiscoverAction) { + viewModelScope.launch { + if (!pendingActions.isClosedForSend) pendingActions.send(action) + } + } } diff --git a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewState.kt b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewState.kt index bc1ca74b13..85ad92b6e7 100644 --- a/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewState.kt +++ b/ui-discover/src/main/java/app/tivi/home/discover/DiscoverViewState.kt @@ -16,6 +16,7 @@ package app.tivi.home.discover +import androidx.compose.runtime.Immutable import app.tivi.data.entities.TraktUser import app.tivi.data.resultentities.EpisodeWithSeasonWithShow import app.tivi.data.resultentities.PopularEntryWithShow @@ -23,6 +24,7 @@ import app.tivi.data.resultentities.RecommendedEntryWithShow import app.tivi.data.resultentities.TrendingEntryWithShow import app.tivi.trakt.TraktAuthState +@Immutable data class DiscoverViewState( val user: TraktUser? = null, val authState: TraktAuthState = TraktAuthState.LOGGED_OUT, @@ -33,4 +35,7 @@ data class DiscoverViewState( val recommendedItems: List = emptyList(), val recommendedRefreshing: Boolean = false, val nextEpisodeWithShowToWatched: EpisodeWithSeasonWithShow? = null -) +) { + val refreshing + get() = trendingRefreshing || popularRefreshing || recommendedRefreshing +} diff --git a/ui-discover/src/main/res/layout/fragment_discover.xml b/ui-discover/src/main/res/layout/fragment_discover.xml deleted file mode 100644 index 0fd12cfdc9..0000000000 --- a/ui-discover/src/main/res/layout/fragment_discover.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ui-discover/src/main/res/layout/view_holder_discover_next_show_episode_to_watch.xml b/ui-discover/src/main/res/layout/view_holder_discover_next_show_episode_to_watch.xml deleted file mode 100644 index 534cbe3ce3..0000000000 --- a/ui-discover/src/main/res/layout/view_holder_discover_next_show_episode_to_watch.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ui-discover/src/main/res/menu/discover_toolbar.xml b/ui-discover/src/main/res/menu/discover_toolbar.xml deleted file mode 100644 index 80e5305625..0000000000 --- a/ui-discover/src/main/res/menu/discover_toolbar.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/ui-discover/src/main/res/values/dimens.xml b/ui-discover/src/main/res/values/dimens.xml deleted file mode 100644 index d5859a011e..0000000000 --- a/ui-discover/src/main/res/values/dimens.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - 140dp - \ No newline at end of file diff --git a/ui-episodedetails/src/main/java/app/tivi/episodedetails/EpisodeDetailsViewState.kt b/ui-episodedetails/src/main/java/app/tivi/episodedetails/EpisodeDetailsViewState.kt index 9aecdd7202..a8f1be7750 100644 --- a/ui-episodedetails/src/main/java/app/tivi/episodedetails/EpisodeDetailsViewState.kt +++ b/ui-episodedetails/src/main/java/app/tivi/episodedetails/EpisodeDetailsViewState.kt @@ -16,11 +16,13 @@ package app.tivi.episodedetails +import androidx.compose.runtime.Immutable import app.tivi.api.UiError import app.tivi.data.entities.Episode import app.tivi.data.entities.EpisodeWatchEntry import app.tivi.data.entities.Season +@Immutable data class EpisodeDetailsViewState( val episodeId: Long, val season: Season? = null, diff --git a/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetails.kt b/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetails.kt index 143906d0fc..20ee31f354 100644 --- a/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetails.kt +++ b/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetails.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.InnerPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.SizeMode import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.Stack import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -50,10 +49,8 @@ import androidx.compose.foundation.layout.preferredWidth import androidx.compose.foundation.layout.preferredWidthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyRowFor import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.material.Divider import androidx.compose.material.EmphasisAmbient import androidx.compose.material.IconButton @@ -94,6 +91,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.LiveData import androidx.ui.tooling.preview.Preview import app.tivi.common.compose.AutoSizedCircularProgressIndicator +import app.tivi.common.compose.Carousel import app.tivi.common.compose.ExpandableFloatingActionButton import app.tivi.common.compose.ExpandingText import app.tivi.common.compose.IconResource @@ -101,6 +99,7 @@ import app.tivi.common.compose.InsetsAmbient import app.tivi.common.compose.LogCompositions import app.tivi.common.compose.PopupMenu import app.tivi.common.compose.PopupMenuItem +import app.tivi.common.compose.PosterCard import app.tivi.common.compose.ProvideDisplayInsets import app.tivi.common.compose.TiviDateFormatterAmbient import app.tivi.common.compose.VectorImage @@ -610,37 +609,21 @@ private fun RelatedShows( ) { LogCompositions("RelatedShows") - LazyRowFor( + Carousel( items = related, - contentPadding = InnerPadding(start = 14.dp, end = 14.dp), + contentPadding = InnerPadding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + itemSpacing = 4.dp, modifier = modifier - ) { item -> - Card( + ) { item, padding -> + PosterCard( + show = item.show, + poster = item.poster, + onClick = { actioner(OpenShowDetails(item.show.id)) }, modifier = Modifier - .padding(vertical = 8.dp, horizontal = 2.dp) + .padding(padding) .fillParentMaxHeight() .aspectRatio(2 / 3f) - ) { - Stack( - Modifier.clickable { actioner(OpenShowDetails(item.show.id)) } - ) { - ProvideEmphasis(EmphasisAmbient.current.medium) { - Text( - text = item.show.title ?: "No title", - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(4.dp) - .gravity(Alignment.CenterStart) - ) - } - val poster = item.poster - if (poster != null) { - CoilImageWithCrossfade( - poster, - modifier = Modifier.matchParentSize() - ) - } - } - } + ) } } diff --git a/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsViewState.kt b/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsViewState.kt index 237d38c1f1..57d3cdea2e 100644 --- a/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsViewState.kt +++ b/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsViewState.kt @@ -16,6 +16,7 @@ package app.tivi.showdetails.details +import androidx.compose.runtime.Immutable import app.tivi.api.UiError import app.tivi.data.entities.ShowTmdbImage import app.tivi.data.entities.TiviShow @@ -24,6 +25,7 @@ import app.tivi.data.resultentities.RelatedShowEntryWithShow import app.tivi.data.resultentities.SeasonWithEpisodesAndWatches import app.tivi.data.views.FollowedShowsWatchStats +@Immutable data class ShowDetailsViewState( val showId: Long, val isFollowed: Boolean = false,