From 6c9a4ad4cca70aa7ed17dd1acfc0a7fb438e69e5 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 11:43:48 +0200 Subject: [PATCH 1/8] feat: add episode account rating on season details --- .../media/repository/ProdMediaRepository.kt | 2 + .../core/database/media/dao/ProdMediaDao.kt | 47 +++++++++++++++++-- .../media/mapper/EpisodeEntityMapper.kt | 5 +- .../database/season/EpisodeRatingEntity.sq | 28 +++++++++++ .../commonMain/sqldelight/migrations/8.sqm | 8 ++++ .../core/datastore/auth/SavedState.kt | 6 +++ .../divinelink/core/model/details/Episode.kt | 1 + .../mapper/details/EpisodeResponseMapper.kt | 5 +- .../details/SeasonDetailsResponseMapper.kt | 18 ++++++- .../details/season/SeasonDetailsResponse.kt | 2 + .../states/EpisodeAccountStatesResponse.kt | 8 ++++ .../model/states/EpisodeRatingResponse.kt | 11 +++++ .../network/media/service/ProdMediaService.kt | 8 +++- .../core/network/media/util/BuildUrl.kt | 5 ++ .../session/model/DeleteSessionRequestApi.kt | 7 --- .../ui/forms/episodes/EpisodesFormContent.kt | 14 ++++++ 16 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeAccountStatesResponse.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeRatingResponse.kt delete mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/session/model/DeleteSessionRequestApi.kt diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index 3964482ac..f0a4097fc 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -2,6 +2,7 @@ package com.divinelink.core.data.media.repository import com.divinelink.core.commons.data import com.divinelink.core.commons.domain.DispatcherProvider +import com.divinelink.core.database.Database import com.divinelink.core.database.media.dao.MediaDao import com.divinelink.core.database.media.mapper.map import com.divinelink.core.database.person.PersonDao @@ -39,6 +40,7 @@ import kotlinx.coroutines.withContext class ProdMediaRepository( private val remote: MediaService, private val dao: MediaDao, + private val database: Database, private val personDao: PersonDao, private val dispatcher: DispatcherProvider, ) : MediaRepository { diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt index 7563dc9be..b22334fdc 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt @@ -11,6 +11,7 @@ import com.divinelink.core.database.SeasonEntity import com.divinelink.core.database.currentEpochSeconds import com.divinelink.core.database.media.mapper.map import com.divinelink.core.database.season.EpisodeEntity +import com.divinelink.core.database.season.EpisodeRatingEntity import com.divinelink.core.database.season.SeasonDetailsEntity import com.divinelink.core.model.Genre import com.divinelink.core.model.details.Episode @@ -22,6 +23,7 @@ import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaReference import com.divinelink.core.model.media.MediaType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlin.time.Clock @@ -314,6 +316,17 @@ class ProdMediaDao( voteCount = episode.voteCount?.toLong() ?: 0, ), ) + + episode.accountRating?.toLong()?.let { rating -> + database.episodeRatingEntityQueries.insertEpisodeRating( + EpisodeRatingEntity( + number = episode.number.toLong(), + showId = episode.showId.toLong(), + season = episode.seasonNumber.toLong(), + rating = rating, + ), + ) + } } } @@ -323,6 +336,14 @@ class ProdMediaDao( seasonNumber: Int, ): Episode = database .transactionWithResult { + val accountRating = database.episodeRatingEntityQueries.fetchEpisodeRating( + number = episodeNumber.toLong(), + showId = showId.toLong(), + season = seasonNumber.toLong(), + ) + .executeAsOneOrNull() + ?.rating + database .episodeEntityQueries .fetchEpisode( @@ -331,7 +352,9 @@ class ProdMediaDao( episodeNumber = episodeNumber.toLong(), ) .executeAsOne() - .map() + .map( + accountRating = accountRating?.toInt(), + ) } override fun fetchEpisodes( @@ -339,7 +362,14 @@ class ProdMediaDao( season: Int, ): Flow> = database .transactionWithResult { - database + val ratingsFlow = database.episodeRatingEntityQueries.fetchEpisodesRatings( + showId = showId.toLong(), + season = season.toLong(), + ) + .asFlow() + .mapToList(dispatcher.io) + + val episodesFlow = database .episodeEntityQueries .fetchEpisodes( showId = showId.toLong(), @@ -347,9 +377,18 @@ class ProdMediaDao( ) .asFlow() .mapToList(dispatcher.io) - .map { entities -> - entities.map { it.map() } + + combine( + episodesFlow, ratingsFlow, + ) { episodes, ratings -> + episodes.map { episode -> + episode.map( + accountRating = ratings.find { + it.number == episode.episodeNumber + }?.rating?.toInt(), + ) } + } } override fun insertSeasonDetails( diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt index c088183c7..6780c4a28 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt @@ -3,7 +3,9 @@ package com.divinelink.core.database.media.mapper import com.divinelink.core.database.season.EpisodeEntity import com.divinelink.core.model.details.Episode -fun EpisodeEntity.map() = Episode( +fun EpisodeEntity.map( + accountRating: Int?, +) = Episode( id = id.toInt(), name = name, airDate = airDate, @@ -17,4 +19,5 @@ fun EpisodeEntity.map() = Episode( voteCount = voteCount.toInt(), crew = emptyList(), guestStars = emptyList(), + accountRating = accountRating, ) diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq new file mode 100644 index 000000000..4cdd14ccd --- /dev/null +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq @@ -0,0 +1,28 @@ +CREATE TABLE EpisodeRatingEntity ( + number INTEGER NOT NULL, + showId INTEGER NOT NULL, + season INTEGER NOT NULL, + rating INTEGER NOT NULL, + UNIQUE(number, showId, season) +); + +insertEpisodeRating: +INSERT OR REPLACE INTO EpisodeRatingEntity ( + number, + showId, + season, + rating +) +VALUES ?; + +fetchEpisodeRating: +SELECT * FROM EpisodeRatingEntity +WHERE showId = ? AND season = ? AND number = ?; + +fetchEpisodesRatings: +SELECT * FROM EpisodeRatingEntity +WHERE showId = ? AND season = ? +ORDER BY number; + +deleteListMediaItem: +DELETE FROM EpisodeRatingEntity WHERE showId = ? AND season = ? AND number = ?; diff --git a/core/database/src/commonMain/sqldelight/migrations/8.sqm b/core/database/src/commonMain/sqldelight/migrations/8.sqm index 418d06dcc..1ccc0dfba 100644 --- a/core/database/src/commonMain/sqldelight/migrations/8.sqm +++ b/core/database/src/commonMain/sqldelight/migrations/8.sqm @@ -63,4 +63,12 @@ CREATE TABLE EpisodeGuestStarEntity( creditId TEXT NOT NULL REFERENCES PersonRoleEntity(creditId), displayOrder INTEGER NOT NULL, PRIMARY KEY (showId, season, episode, creditId) +); + +CREATE TABLE EpisodeRatingEntity ( + number INTEGER NOT NULL, + showId INTEGER NOT NULL, + season INTEGER NOT NULL, + rating INTEGER NOT NULL, + UNIQUE(number, showId, season) ); \ No newline at end of file diff --git a/core/datastore/src/commonMain/kotlin/com/divinelink/core/datastore/auth/SavedState.kt b/core/datastore/src/commonMain/kotlin/com/divinelink/core/datastore/auth/SavedState.kt index 692f33de7..fba33de50 100644 --- a/core/datastore/src/commonMain/kotlin/com/divinelink/core/datastore/auth/SavedState.kt +++ b/core/datastore/src/commonMain/kotlin/com/divinelink/core/datastore/auth/SavedState.kt @@ -79,6 +79,12 @@ val SavedStateStorage.observedTmdbSession .map { it.tmdbSession } .distinctUntilChanged() +val SavedStateStorage.tmdbSessionId + get() = savedState + .value + .tmdbSession + ?.sessionId + val SavedStateStorage.accessToken get() = savedState .value diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt index 22848b438..7285ef9ee 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt @@ -17,4 +17,5 @@ data class Episode( val voteCount: Int?, val crew: List, val guestStars: List, + val accountRating: Int?, ) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt index 75164417e..07b0343e8 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt @@ -5,7 +5,9 @@ import com.divinelink.core.network.media.model.details.season.EpisodeResponse import com.divinelink.core.network.media.model.details.toHourMinuteFormat import com.divinelink.core.network.media.model.details.toPerson -fun EpisodeResponse.map() = Episode( +fun EpisodeResponse.map( + accountRating: Int? +) = Episode( id = id, name = name, airDate = airDate, @@ -19,4 +21,5 @@ fun EpisodeResponse.map() = Episode( number = episodeNumber, crew = crew.map(), guestStars = guestStars.map { it.toPerson() }, + accountRating = accountRating, ) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt index 1c13ae6c7..7c1aeb71a 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt @@ -7,6 +7,8 @@ import com.divinelink.core.model.person.Gender import com.divinelink.core.network.media.model.details.season.EpisodeResponse import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse import com.divinelink.core.network.media.model.details.toHourMinuteFormat +import com.divinelink.core.network.media.model.states.EpisodeAccountStatesResponse +import com.divinelink.core.network.media.model.states.RateResponseApi fun SeasonDetailsResponse.map(): SeasonDetails = SeasonDetails( id = id, @@ -20,10 +22,24 @@ fun SeasonDetailsResponse.map(): SeasonDetails = SeasonDetails( .filter { it.runtime != null } .sumOf { it.runtime!! } .toHourMinuteFormat(), - episodes = episodes.map { it.map() }, + episodes = episodes.map { episode -> + episode.map(accountRating = ratings.map(episode.id)) + }, guestStars = aggregateGuestStars(episodes), ) +private fun EpisodeAccountStatesResponse?.map(episodeId: Int): Int? { + val episodeRating = this + ?.results + ?.find { ratingResponse -> ratingResponse.id == episodeId } + + return when (episodeRating?.rated) { + RateResponseApi.False -> null + is RateResponseApi.Value -> episodeRating.rated.value.toInt() + null -> null + } +} + private fun aggregateGuestStars(allEpisodes: List): List = allEpisodes .flatMap { it.guestStars } .groupBy { it.id } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt index 87bd83645..150c49d8b 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt @@ -1,5 +1,6 @@ package com.divinelink.core.network.media.model.details.season +import com.divinelink.core.network.media.model.states.EpisodeAccountStatesResponse import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -12,4 +13,5 @@ data class SeasonDetailsResponse( @SerialName("poster_path") val posterPath: String?, @SerialName("vote_average") val voteAverage: Double, val episodes: List, + @SerialName("account_states") val ratings: EpisodeAccountStatesResponse?, ) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeAccountStatesResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeAccountStatesResponse.kt new file mode 100644 index 000000000..9e5383544 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeAccountStatesResponse.kt @@ -0,0 +1,8 @@ +package com.divinelink.core.network.media.model.states + +import kotlinx.serialization.Serializable + +@Serializable +data class EpisodeAccountStatesResponse( + val results: List, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeRatingResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeRatingResponse.kt new file mode 100644 index 000000000..0c9ad15aa --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/states/EpisodeRatingResponse.kt @@ -0,0 +1,11 @@ +package com.divinelink.core.network.media.model.states + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EpisodeRatingResponse( + val id: Int, + @SerialName("episode_number") val episodeNumber: Int, + val rated: RateResponseApi, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index 60ac6d54d..81b896a36 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -1,5 +1,7 @@ package com.divinelink.core.network.media.service +import com.divinelink.core.datastore.auth.SavedStateStorage +import com.divinelink.core.datastore.auth.tmdbSessionId import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.home.MediaListRequest import com.divinelink.core.model.media.MediaType @@ -36,7 +38,10 @@ import com.divinelink.core.network.runCatchingWithNetworkRetry import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -class ProdMediaService(private val restClient: TMDbClient) : MediaService { +class ProdMediaService( + private val restClient: TMDbClient, + private val encryptedStorage: SavedStateStorage, +) : MediaService { override suspend fun fetchMediaLists( request: MediaListRequest, @@ -264,6 +269,7 @@ class ProdMediaService(private val restClient: TMDbClient) : MediaService { url = buildSeasonDetailsUrl( showId = showId, seasonNumber = season, + sessionId = encryptedStorage.tmdbSessionId ), ) } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt index 9b109773c..6fdc6561d 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt @@ -134,6 +134,7 @@ fun buildFetchMediaListUrl( fun buildSeasonDetailsUrl( showId: Int, seasonNumber: Int, + sessionId: String?, ): String = buildUrl { protocol = URLProtocol.HTTPS host = Routes.TMDb.HOST @@ -141,5 +142,9 @@ fun buildSeasonDetailsUrl( parameters.apply { append("language", "en") + sessionId?.let { id -> + append("append_to_response", "account_states") + append("session_id", id) + } } }.toString() diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/session/model/DeleteSessionRequestApi.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/session/model/DeleteSessionRequestApi.kt deleted file mode 100644 index ae5a2a6fb..000000000 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/session/model/DeleteSessionRequestApi.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.divinelink.core.network.session.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class DeleteSessionRequestApi(@SerialName("session_id") val sessionId: String) diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt index 330352c3b..7385e3975 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt @@ -1,5 +1,6 @@ package com.divinelink.feature.season.ui.forms.episodes +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -23,6 +24,7 @@ import com.divinelink.core.model.details.Episode import com.divinelink.core.model.details.season.SeasonData import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.ui.components.details.AirDateWithRuntime +import com.divinelink.core.ui.extension.getColorRating @Composable fun EpisodesFormContent( @@ -98,6 +100,18 @@ fun EpisodeItem( style = MaterialTheme.typography.bodySmall, ) } + + Spacer(modifier = Modifier.weight(1f)) + + AnimatedContent( + targetState = episode.accountRating != null, + ) { + Text( + modifier = Modifier.padding(end = MaterialTheme.dimensions.keyline_8), + text = episode.accountRating?.toString() ?: "", + color = episode.accountRating?.toDouble().getColorRating(), + ) + } } HorizontalDivider( From 257b7e1f9d42f674e328d97f68a0fa6418871e76 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 12:29:43 +0200 Subject: [PATCH 2/8] feat: repackage rate modal to add to account module --- .../composeResources/values/strings.xml | 1 + .../divinelink/core/ui/button/RatingButton.kt | 51 +++++++++++++++++ .../composeResources/values/strings.xml | 4 ++ .../add/to/account}/rate/RateDialogContent.kt | 55 +++++++++---------- .../to/account}/rate/RateModalBottomSheet.kt | 2 +- .../add/to/account}/rate/RateSlider.kt | 2 +- .../to/account/rate}/RateDialogContentTest.kt | 23 ++++---- .../composeResources/values/strings.xml | 4 -- .../feature/details/media/ui/DetailsScreen.kt | 2 +- .../components/CollapsibleDetailsContent.kt | 44 +-------------- .../DetailsExpandableFloatingActionButton.kt | 7 ++- 11 files changed, 100 insertions(+), 95 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/button/RatingButton.kt rename feature/{details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui => add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account}/rate/RateDialogContent.kt (71%) rename feature/{details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui => add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account}/rate/RateModalBottomSheet.kt (94%) rename feature/{details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui => add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account}/rate/RateSlider.kt (97%) rename feature/{details/src/commonTest/kotlin/com/divinelink/feature/details => add-to-account/src/commonTest/kotlin/com/divinelink/feature/add/to/account/rate}/RateDialogContentTest.kt (75%) diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index e3efe59ca..28621298f 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -14,6 +14,7 @@ Save Watch Trailer + Rate this Your rating You're back online diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/button/RatingButton.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/button/RatingButton.kt new file mode 100644 index 000000000..84362ec99 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/button/RatingButton.kt @@ -0,0 +1,51 @@ +package com.divinelink.core.ui.button + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.designsystem.theme.shape +import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.rating.YourRatingText +import com.divinelink.core.ui.resources.Res +import com.divinelink.core.ui.resources.core_ui_add_rating +import org.jetbrains.compose.resources.stringResource + +@Composable +fun RatingButton( + modifier: Modifier = Modifier, + accountRating: Int?, + onClick: () -> Unit, + isLoading: Boolean, +) { + ElevatedButton( + shape = MaterialTheme.shape.large, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = MaterialTheme.dimensions.keyline_2, + ), + modifier = modifier.testTag(TestTags.Details.RATE_THIS_BUTTON), + onClick = onClick, + ) { + AnimatedContent(isLoading) { loading -> + if (loading) { + CircularProgressIndicator(modifier = Modifier.size(MaterialTheme.dimensions.keyline_24)) + } else { + if (accountRating != null) { + YourRatingText(modifier, accountRating) + } else { + Text( + text = stringResource(Res.string.core_ui_add_rating), + style = MaterialTheme.typography.titleSmall, + ) + } + } + } + } +} diff --git a/feature/add-to-account/src/commonMain/composeResources/values/strings.xml b/feature/add-to-account/src/commonMain/composeResources/values/strings.xml index 936a63091..dc37364c4 100644 --- a/feature/add-to-account/src/commonMain/composeResources/values/strings.xml +++ b/feature/add-to-account/src/commonMain/composeResources/values/strings.xml @@ -18,4 +18,8 @@ You cannot remove from the list while you're offline %1$s removed from %2$s %1$s items removed from %2$s + + %1$s?]]> + Clear my rating + Submit rating \ No newline at end of file diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateDialogContent.kt b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContent.kt similarity index 71% rename from feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateDialogContent.kt rename to feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContent.kt index d768de200..a321503dc 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateDialogContent.kt +++ b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContent.kt @@ -1,4 +1,4 @@ -package com.divinelink.feature.details.media.ui.rate +package com.divinelink.feature.add.to.account.rate import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -9,7 +9,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -19,17 +18,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.divinelink.core.designsystem.theme.AppTheme import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.ui.Previews import com.divinelink.core.ui.UiString import com.divinelink.core.ui.components.details.SpannableRating +import com.divinelink.core.ui.composition.PreviewLocalProvider import com.divinelink.core.ui.fromHtml import com.divinelink.core.ui.resources.core_ui_your_rating -import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating_description -import com.divinelink.feature.details.resources.details__clear_my_rating -import com.divinelink.feature.details.resources.details__submit_rating_button +import com.divinelink.feature.add.to.account.resources.Res +import com.divinelink.feature.add.to.account.resources.add_rating_description +import com.divinelink.feature.add.to.account.resources.clear_my_rating +import com.divinelink.feature.add.to.account.resources.submit_rating_button import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt @@ -51,7 +50,7 @@ fun RateDialogContent( .padding(MaterialTheme.dimensions.keyline_16), ) { Text( - text = stringResource(Res.string.details__add_rating_description, mediaTitle).fromHtml(), + text = stringResource(Res.string.add_rating_description, mediaTitle).fromHtml(), ) Spacer( @@ -81,7 +80,7 @@ fun RateDialogContent( .align(Alignment.End), onClick = onClearRate, ) { - Text(text = stringResource(Res.string.details__clear_my_rating)) + Text(text = stringResource(Res.string.clear_my_rating)) } } @@ -91,7 +90,7 @@ fun RateDialogContent( onClick = { onSubmitRate(rating.roundToInt()) }, ) { Text( - text = stringResource(Res.string.details__submit_rating_button), + text = stringResource(Res.string.submit_rating_button), ) } } @@ -100,31 +99,27 @@ fun RateDialogContent( @Previews @Composable private fun BottomSheetRateContentPreview() { - AppTheme { - Surface { - RateDialogContent( - value = 5f, - mediaTitle = "The Godfather", - onSubmitRate = {}, - onClearRate = {}, - canClearRate = true, - ) - } + PreviewLocalProvider { + RateDialogContent( + value = 5f, + mediaTitle = "The Godfather", + onSubmitRate = {}, + onClearRate = {}, + canClearRate = true, + ) } } @Previews @Composable private fun BottomSheetRateContentWithoutClearPreview() { - AppTheme { - Surface { - RateDialogContent( - value = 0f, - mediaTitle = "The Godfather", - onSubmitRate = {}, - onClearRate = {}, - canClearRate = false, - ) - } + PreviewLocalProvider { + RateDialogContent( + value = 0f, + mediaTitle = "The Godfather", + onSubmitRate = {}, + onClearRate = {}, + canClearRate = false, + ) } } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateModalBottomSheet.kt b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt similarity index 94% rename from feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateModalBottomSheet.kt rename to feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt index 66181904b..b13ef6f11 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateModalBottomSheet.kt +++ b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt @@ -1,4 +1,4 @@ -package com.divinelink.feature.details.media.ui.rate +package com.divinelink.feature.add.to.account.rate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateSlider.kt b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateSlider.kt similarity index 97% rename from feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateSlider.kt rename to feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateSlider.kt index 89ad94c38..d2b3a6e55 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/rate/RateSlider.kt +++ b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateSlider.kt @@ -1,4 +1,4 @@ -package com.divinelink.feature.details.media.ui.rate +package com.divinelink.feature.add.to.account.rate import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.Arrangement diff --git a/feature/details/src/commonTest/kotlin/com/divinelink/feature/details/RateDialogContentTest.kt b/feature/add-to-account/src/commonTest/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContentTest.kt similarity index 75% rename from feature/details/src/commonTest/kotlin/com/divinelink/feature/details/RateDialogContentTest.kt rename to feature/add-to-account/src/commonTest/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContentTest.kt index bfff25bb1..116a744db 100644 --- a/feature/details/src/commonTest/kotlin/com/divinelink/feature/details/RateDialogContentTest.kt +++ b/feature/add-to-account/src/commonTest/kotlin/com/divinelink/feature/add/to/account/rate/RateDialogContentTest.kt @@ -1,4 +1,4 @@ -package com.divinelink.feature.details +package com.divinelink.feature.add.to.account.rate import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertIsNotEnabled @@ -9,11 +9,10 @@ import com.divinelink.core.testing.ComposeTest import com.divinelink.core.testing.setContentWithTheme import com.divinelink.core.testing.uiTest import com.divinelink.core.ui.fromHtml -import com.divinelink.feature.details.media.ui.rate.RateDialogContent -import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating_description -import com.divinelink.feature.details.resources.details__clear_my_rating -import com.divinelink.feature.details.resources.details__submit_rating_button +import com.divinelink.feature.add.to.account.resources.Res +import com.divinelink.feature.add.to.account.resources.add_rating_description +import com.divinelink.feature.add.to.account.resources.clear_my_rating +import com.divinelink.feature.add.to.account.resources.submit_rating_button import io.kotest.matchers.shouldBe import org.jetbrains.compose.resources.getString import kotlin.test.Test @@ -40,11 +39,11 @@ class RateDialogContentTest : ComposeTest() { } val descriptionText = getString( - Res.string.details__add_rating_description, + Res.string.add_rating_description, movie.title, ).fromHtml() - val submitButtonText = getString(Res.string.details__submit_rating_button) + val submitButtonText = getString(Res.string.submit_rating_button) onNodeWithText(descriptionText.text).assertExists() onNodeWithText(submitButtonText).performClick() @@ -70,11 +69,11 @@ class RateDialogContentTest : ComposeTest() { } val descriptionText = getString( - Res.string.details__add_rating_description, + Res.string.add_rating_description, movie.title, ).fromHtml() - val submitButtonText = getString(Res.string.details__submit_rating_button) + val submitButtonText = getString(Res.string.submit_rating_button) onNodeWithText(descriptionText.text).assertExists() onNodeWithText(submitButtonText).assertIsNotEnabled() @@ -101,9 +100,9 @@ class RateDialogContentTest : ComposeTest() { } val descriptionText = - getString(Res.string.details__add_rating_description, movie.title).fromHtml() + getString(Res.string.add_rating_description, movie.title).fromHtml() - val deleteButtonText = getString(Res.string.details__clear_my_rating) + val deleteButtonText = getString(Res.string.clear_my_rating) onNodeWithText(descriptionText.text).assertExists() onNodeWithText(deleteButtonText).performClick() diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index 967591296..39948fbc5 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -2,10 +2,6 @@ Watchlist Add to list - Rate this - %1$s?]]> - Clear my rating - Submit rating Rating submitted for %1$s Rating cleared for %1$s diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt index baf522822..faca4e6c8 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt @@ -27,7 +27,7 @@ import com.divinelink.core.navigation.route.toPersonRoute import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.components.details.videos.YouTubePlayerScreen import com.divinelink.core.ui.manager.url.rememberUrlHandler -import com.divinelink.feature.details.media.ui.rate.RateModalBottomSheet +import com.divinelink.feature.add.to.account.rate.RateModalBottomSheet import com.divinelink.feature.details.media.ui.ratings.AllRatingsModalBottomSheet import org.koin.compose.viewmodel.koinViewModel diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt index a9c72cd4e..cd733f594 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt @@ -1,6 +1,5 @@ package com.divinelink.feature.details.media.ui.components -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope @@ -14,21 +13,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import com.divinelink.core.designsystem.theme.dimensions -import com.divinelink.core.designsystem.theme.shape import com.divinelink.core.model.ImageQuality import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.details.AccountDataSection @@ -40,6 +33,7 @@ import com.divinelink.core.model.jellyseerr.media.JellyseerrStatus import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.ui.SharedElementKeys import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.button.RatingButton import com.divinelink.core.ui.coil.PosterImage import com.divinelink.core.ui.components.AddToListButton import com.divinelink.core.ui.components.JellyseerrStatusPill @@ -48,10 +42,6 @@ import com.divinelink.core.ui.components.WatchlistButton import com.divinelink.core.ui.conditional import com.divinelink.core.ui.mediaImageDropShadow import com.divinelink.core.ui.rating.MediaRatingItem -import com.divinelink.core.ui.rating.YourRatingText -import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating -import org.jetbrains.compose.resources.stringResource @Composable fun SharedTransitionScope.CollapsibleDetailsContent( @@ -186,35 +176,3 @@ fun SharedTransitionScope.CollapsibleDetailsContent( } } } - -@Composable -fun RatingButton( - modifier: Modifier = Modifier, - accountRating: Int?, - onClick: () -> Unit, - isLoading: Boolean, -) { - ElevatedButton( - shape = MaterialTheme.shape.large, - elevation = ButtonDefaults.buttonElevation( - defaultElevation = MaterialTheme.dimensions.keyline_2, - ), - modifier = modifier.testTag(TestTags.Details.RATE_THIS_BUTTON), - onClick = onClick, - ) { - AnimatedContent(isLoading) { loading -> - if (loading) { - CircularProgressIndicator(modifier = Modifier.size(MaterialTheme.dimensions.keyline_24)) - } else { - if (accountRating != null) { - YourRatingText(modifier, accountRating) - } else { - Text( - text = stringResource(Res.string.details__add_rating), - style = MaterialTheme.typography.titleSmall, - ) - } - } - } - } -} diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/fab/DetailsExpandableFloatingActionButton.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/fab/DetailsExpandableFloatingActionButton.kt index 892624259..0ec189c02 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/fab/DetailsExpandableFloatingActionButton.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/fab/DetailsExpandableFloatingActionButton.kt @@ -12,9 +12,10 @@ import com.divinelink.core.model.UIText import com.divinelink.core.model.details.DetailActionItem import com.divinelink.core.scaffold.ScaffoldState import com.divinelink.core.ui.IconWrapper +import com.divinelink.core.ui.UiString import com.divinelink.core.ui.components.expandablefab.FloatingActionButtonItem +import com.divinelink.core.ui.resources.core_ui_add_rating import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating import com.divinelink.feature.details.resources.feature_details__add_to_list import com.divinelink.feature.details.resources.feature_details__watchlist import com.divinelink.feature.details.resources.feature_details_manage_movie @@ -38,8 +39,8 @@ internal fun ScaffoldState.DetailsExpandableFloatingActionButton( when (button) { DetailActionItem.Rate -> FloatingActionButtonItem( icon = IconWrapper.Vector(Icons.Rounded.StarRate), - label = UIText.ResourceText(Res.string.details__add_rating), - contentDescription = UIText.ResourceText(Res.string.details__add_rating), + label = UIText.ResourceText(UiString.core_ui_add_rating), + contentDescription = UIText.ResourceText(UiString.core_ui_add_rating), onClick = onAddRateClicked, ) DetailActionItem.Watchlist -> FloatingActionButtonItem( From db100defd8bf54a64e54fa31469a0011efb1dd69 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 12:47:39 +0200 Subject: [PATCH 3/8] feat: add rate button on episode details --- .../to/account/rate/RateModalBottomSheet.kt | 3 +- feature/episode/build.gradle.kts | 2 + .../feature/episode/ui/EpisodeContent.kt | 1 + .../feature/episode/ui/EpisodeTitleDetails.kt | 56 ++++++++++++++++--- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt index b13ef6f11..2c51e84a0 100644 --- a/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt +++ b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt @@ -3,6 +3,7 @@ package com.divinelink.feature.add.to.account.rate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -10,7 +11,7 @@ import androidx.compose.ui.Modifier @Composable fun RateModalBottomSheet( modifier: Modifier = Modifier, - sheetState: SheetState, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), value: Int?, mediaTitle: String, canClearRate: Boolean, diff --git a/feature/episode/build.gradle.kts b/feature/episode/build.gradle.kts index e08290e81..93321831d 100644 --- a/feature/episode/build.gradle.kts +++ b/feature/episode/build.gradle.kts @@ -11,6 +11,8 @@ kotlin { implementation(projects.core.data) implementation(projects.core.domain) + implementation(projects.feature.addToAccount) + implementation(libs.kotlinx.datetime) } } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt index e3338e704..54e5face5 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt @@ -93,6 +93,7 @@ fun SharedTransitionScope.EpisodeContent( ) { EpisodeTitleDetails( onNavigate = onNavigate, + action = action, title = uiState.showTitle, season = uiState.seasonTitle, episode = episode, diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt index 908c839ee..e7a8b538d 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt @@ -4,26 +4,54 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.model.details.Episode import com.divinelink.core.model.details.rating.RatingDetails import com.divinelink.core.model.details.rating.RatingSource import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.button.RatingButton import com.divinelink.core.ui.components.details.AirDateWithRuntime import com.divinelink.core.ui.rating.MediaRatingItem +import com.divinelink.feature.add.to.account.rate.RateModalBottomSheet +import com.divinelink.feature.episode.EpisodeAction +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EpisodeTitleDetails( modifier: Modifier = Modifier, onNavigate: (Navigation) -> Unit, + action: (EpisodeAction) -> Unit, title: String, season: String, episode: Episode, ) { + var showRateModal by rememberSaveable { mutableStateOf(false) } + + if (showRateModal) { + RateModalBottomSheet( + modifier = modifier, + value = episode.accountRating, + mediaTitle = episode.name, + canClearRate = true, + onSubmitRate = { + }, + onClearRate = { + }, + onDismissRequest = { showRateModal = false }, + ) + } + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), @@ -61,12 +89,26 @@ fun EpisodeTitleDetails( style = MaterialTheme.typography.titleSmall, ) - MediaRatingItem( - ratingDetails = RatingDetails.Score( - voteAverage = episode.voteAverage?.toDouble() ?: 0.0, - voteCount = episode.voteCount ?: 0, - ), - source = RatingSource.TMDB, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MediaRatingItem( + ratingDetails = RatingDetails.Score( + voteAverage = episode.voteAverage?.toDouble() ?: 0.0, + voteCount = episode.voteCount ?: 0, + ), + source = RatingSource.TMDB, + ) + + Spacer( + modifier = Modifier.weight(1f) + ) + + RatingButton( + accountRating = episode.accountRating, + onClick = { showRateModal = true }, + isLoading = false, + ) + } } } From 890059ff238c0e967dd42ba49fdf79d200ff8c47 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 20:02:09 +0200 Subject: [PATCH 4/8] feat: implement episode submit rating --- .../repository/ProdDetailsRepositoryTest.kt | 10 +- .../core/data/account/AccountRepository.kt | 10 ++ .../data/account/ProdAccountRepository.kt | 24 ++++ .../core/data/auth/ProdAuthRepository.kt | 1 + .../data/media/repository/MediaRepository.kt | 7 ++ .../media/repository/ProdMediaRepository.kt | 24 +++- .../core/database/media/dao/MediaDao.kt | 9 +- .../core/database/media/dao/ProdMediaDao.kt | 42 +++++-- .../media/mapper/EpisodeEntityMapper.kt | 4 +- .../media/service/ProdMediaServiceTest.kt | 8 +- .../com/divinelink/core/network/Utilities.kt | 4 +- .../network/account/service/AccountService.kt | 8 ++ .../account/service/ProdAccountService.kt | 34 +++++- .../core/network/account/util/BuildUrl.kt | 15 +++ .../mapper/details/EpisodeResponseMapper.kt | 4 +- ...itOnAccountResponse.kt => TMDBResponse.kt} | 2 +- .../network/media/service/MediaService.kt | 8 +- .../network/media/service/ProdMediaService.kt | 64 +++++----- .../core/network/model/ValueRequest.kt | 8 ++ .../core/navigation/route/Navigation.kt | 1 - .../core/testing/service/TestMediaService.kt | 8 +- .../composeResources/values/strings.xml | 4 + .../to/account/rate/RateModalBottomSheet.kt | 8 +- .../composeResources/values/strings.xml | 3 - .../feature/details/media/ui/DetailsScreen.kt | 1 - .../details/media/ui/DetailsViewModel.kt | 13 +- .../feature/episode/EpisodeAction.kt | 4 + .../feature/episode/EpisodeUiState.kt | 5 + .../feature/episode/EpisodeViewModel.kt | 112 ++++++++++++++++++ .../feature/episode/ui/EpisodeContent.kt | 5 +- .../feature/episode/ui/EpisodeScreen.kt | 14 +++ .../feature/episode/ui/EpisodeTitleDetails.kt | 55 +++++---- 32 files changed, 398 insertions(+), 121 deletions(-) rename core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/{SubmitOnAccountResponse.kt => TMDBResponse.kt} (89%) create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/model/ValueRequest.kt diff --git a/app/src/androidHostTest/kotlin/com/divinelink/scenepeek/search/domain/repository/ProdDetailsRepositoryTest.kt b/app/src/androidHostTest/kotlin/com/divinelink/scenepeek/search/domain/repository/ProdDetailsRepositoryTest.kt index 29822d1a4..ee7846c10 100644 --- a/app/src/androidHostTest/kotlin/com/divinelink/scenepeek/search/domain/repository/ProdDetailsRepositoryTest.kt +++ b/app/src/androidHostTest/kotlin/com/divinelink/scenepeek/search/domain/repository/ProdDetailsRepositoryTest.kt @@ -33,7 +33,7 @@ import com.divinelink.core.network.media.model.details.toDomainMedia import com.divinelink.core.network.media.model.details.videos.VideoResultsApi import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.rating.AddRatingRequestApi import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import com.divinelink.core.network.media.model.states.AccountMediaDetailsRequestApi @@ -470,7 +470,7 @@ class ProdDetailsRepositoryTest { ) val response = Result.success( - SubmitOnAccountResponse( + TMDBResponse( statusMessage = "Success", statusCode = 1, success = true, @@ -499,7 +499,7 @@ class ProdDetailsRepositoryTest { ) val response = Result.success( - SubmitOnAccountResponse( + TMDBResponse( statusMessage = "Success", statusCode = 1, success = true, @@ -528,7 +528,7 @@ class ProdDetailsRepositoryTest { ) val response = Result.success( - SubmitOnAccountResponse( + TMDBResponse( statusMessage = "Success", statusCode = 1, success = true, @@ -559,7 +559,7 @@ class ProdDetailsRepositoryTest { ) val response = Result.success( - SubmitOnAccountResponse( + TMDBResponse( statusMessage = "Success", statusCode = 1, success = true, diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt index e05102451..4cca3725d 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt @@ -2,6 +2,7 @@ package com.divinelink.core.data.account import com.divinelink.core.model.PaginationData import com.divinelink.core.model.media.MediaItem +import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import kotlinx.coroutines.flow.Flow interface AccountRepository { @@ -33,4 +34,13 @@ interface AccountRepository { accountId: String, sessionId: String, ): Flow>> + + suspend fun submitEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result + + suspend fun deleteEpisodeRating(request: DeleteRatingRequestApi): Result } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt index c72954ab2..e9b77e668 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt @@ -2,10 +2,12 @@ package com.divinelink.core.data.account import com.divinelink.core.database.media.dao.MediaDao import com.divinelink.core.model.PaginationData +import com.divinelink.core.model.exception.AppException import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.account.service.AccountService import com.divinelink.core.network.media.model.movie.map +import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import com.divinelink.core.network.media.model.tv.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -118,4 +120,26 @@ class ProdAccountRepository( Result.success(data.copy(list = updatedTvShows)) } + + override suspend fun deleteEpisodeRating(request: DeleteRatingRequestApi): Result { + TODO("Not yet implemented") + } + + override suspend fun submitEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result = remote.submitEpisodeRating( + showId = showId, + season = season, + number = number, + rating = rating, + ).map { response -> + if (response.success) { + Result.success(Unit) + } else { + Result.failure(AppException.Unknown()) + } + } } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt index fe1914b9a..84316ddad 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt @@ -67,6 +67,7 @@ class ProdAuthRepository(private val savedStateStorage: SavedStateStorage) : Aut } override suspend fun clearTMDBSession() { + // TODO Clear saved episode ratings data savedStateStorage.clearTMDBSession() } } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt index 4a34f1e06..0a01b1324 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt @@ -110,4 +110,11 @@ interface MediaRepository { showId: Int, season: Int, ): Result> + + fun insertEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index f0a4097fc..703bbb398 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -367,12 +367,10 @@ class ProdMediaRepository( episode = number, season = season, ), - flowOf( - dao.fetchEpisode( - showId = showId, - episodeNumber = number, - seasonNumber = season, - ), + dao.fetchEpisode( + showId = showId, + episodeNumber = number, + seasonNumber = season, ), ) { guestStars, episode -> runCatching { @@ -381,4 +379,18 @@ class ProdMediaRepository( ) } } + + override fun insertEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result = runCatching { + dao.insertEpisodeRating( + showId = showId, + season = season, + number = number, + rating = rating, + ) + } } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt index 6e9ac0222..142035eab 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt @@ -79,7 +79,7 @@ interface MediaDao { showId: Int, episodeNumber: Int, seasonNumber: Int, - ): Episode + ): Flow fun fetchEpisodes( showId: Int, @@ -101,4 +101,11 @@ interface MediaDao { season: Int, showId: Int, ): List + + fun insertEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ) } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt index b22334fdc..a32339b90 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt @@ -334,27 +334,33 @@ class ProdMediaDao( showId: Int, episodeNumber: Int, seasonNumber: Int, - ): Episode = database + ): Flow = database .transactionWithResult { val accountRating = database.episodeRatingEntityQueries.fetchEpisodeRating( number = episodeNumber.toLong(), showId = showId.toLong(), season = seasonNumber.toLong(), ) - .executeAsOneOrNull() - ?.rating + .asFlow() + .mapToOneOrNull(dispatcher.io) + .map { it?.rating } - database + val episode = database .episodeEntityQueries .fetchEpisode( showId = showId.toLong(), seasonNumber = seasonNumber.toLong(), episodeNumber = episodeNumber.toLong(), ) - .executeAsOne() - .map( - accountRating = accountRating?.toInt(), - ) + .asFlow() + .mapToOne(dispatcher.io) + + combine( + episode, + accountRating, + ) { episode, rating -> + episode.map(rating?.toInt()) + } } override fun fetchEpisodes( @@ -379,7 +385,8 @@ class ProdMediaDao( .mapToList(dispatcher.io) combine( - episodesFlow, ratingsFlow, + episodesFlow, + ratingsFlow, ) { episodes, ratings -> episodes.map { episode -> episode.map( @@ -442,4 +449,21 @@ class ProdMediaDao( .executeAsList() .map { it.toInt() } } + + override fun insertEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ) = database + .transaction { + database.episodeRatingEntityQueries.insertEpisodeRating( + EpisodeRatingEntity( + number = number.toLong(), + showId = showId.toLong(), + season = season.toLong(), + rating = rating.toLong(), + ), + ) + } } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt index 6780c4a28..3b35dbf7b 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt @@ -3,9 +3,7 @@ package com.divinelink.core.database.media.mapper import com.divinelink.core.database.season.EpisodeEntity import com.divinelink.core.model.details.Episode -fun EpisodeEntity.map( - accountRating: Int?, -) = Episode( +fun EpisodeEntity.map(accountRating: Int?) = Episode( id = id.toInt(), name = name, airDate = airDate, diff --git a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt index e1eea9913..dd4349472 100644 --- a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt +++ b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt @@ -3,7 +3,7 @@ package com.divinelink.core.network.media.service import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.rating.AddRatingRequestApi import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import com.divinelink.core.testing.factories.api.media.GenreResponseFactory @@ -49,7 +49,7 @@ class ProdMediaServiceTest { assertThat(result).isEqualTo( Result.success( - SubmitOnAccountResponse( + TMDBResponse( success = true, statusCode = 13, statusMessage = "The item/record was deleted successfully.", @@ -142,7 +142,7 @@ class ProdMediaServiceTest { assertThat(result).isEqualTo( Result.success( - SubmitOnAccountResponse( + TMDBResponse( success = true, statusCode = 13, statusMessage = "The item/record was deleted successfully.", @@ -177,7 +177,7 @@ class ProdMediaServiceTest { assertThat(result).isEqualTo( Result.success( - SubmitOnAccountResponse( + TMDBResponse( success = true, statusCode = 13, statusMessage = "The item/record was deleted successfully.", diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/Utilities.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/Utilities.kt index fee891c60..04724c9c7 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/Utilities.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/Utilities.kt @@ -3,7 +3,7 @@ package com.divinelink.core.network import com.divinelink.core.network.list.model.add.AddToListResponse -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import io.ktor.client.plugins.ResponseException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -70,7 +70,7 @@ internal suspend inline fun runCatchingWithNetworkRetry( repeat(times) { retry -> try { when (val response = block()) { - is SubmitOnAccountResponse -> { + is TMDBResponse -> { if (response.success) { return Result.success(response as T) } else if (response.statusCode == 34) { diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt index c06b08d5f..4ec4453c8 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt @@ -1,5 +1,6 @@ package com.divinelink.core.network.account.service +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.tv.TvResponseApi import kotlinx.coroutines.flow.Flow @@ -33,4 +34,11 @@ interface AccountService { accountId: String, sessionId: String, ): Flow + + suspend fun submitEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt index 0c00b26e3..107a5ae97 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt @@ -1,12 +1,21 @@ package com.divinelink.core.network.account.service +import com.divinelink.core.datastore.auth.SavedStateStorage +import com.divinelink.core.datastore.auth.tmdbSessionId +import com.divinelink.core.model.exception.SessionException +import com.divinelink.core.network.account.util.buildSubmitEpisodeRating import com.divinelink.core.network.client.TMDbClient +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.tv.TvResponseApi +import com.divinelink.core.network.model.ValueRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -class ProdAccountService(private val restClient: TMDbClient) : AccountService { +class ProdAccountService( + private val restClient: TMDbClient, + private val storage: SavedStateStorage, +) : AccountService { override fun fetchMoviesWatchlist( page: Int, @@ -71,4 +80,27 @@ class ProdAccountService(private val restClient: TMDbClient) : AccountService { emit(response) } + + override suspend fun submitEpisodeRating( + showId: Int, + season: Int, + number: Int, + rating: Int, + ): Result = runCatching { + if (storage.tmdbSessionId == null) { + throw SessionException.Unauthenticated() + } else { + val url = buildSubmitEpisodeRating( + showId = showId, + season = season, + number = number, + sessionId = storage.tmdbSessionId!!, + ) + + restClient.post( + url = url, + body = ValueRequest(rating.toFloat()), + ) + } + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt index b087ddec4..97285ac77 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt @@ -18,3 +18,18 @@ internal fun buildFetchListsUrl( append("page", page.toString()) } }.toString() + +internal fun buildSubmitEpisodeRating( + showId: Int, + season: Int, + number: Int, + sessionId: String, +): String = buildUrl { + protocol = URLProtocol.HTTPS + host = Routes.TMDb.HOST + encodedPath = Routes.TMDb.V3 + "/tv/$showId/season/$season/episode/$number/rating" + + parameters.apply { + append("session_id", sessionId) + } +}.toString() diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt index 07b0343e8..8b072cdf9 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt @@ -5,9 +5,7 @@ import com.divinelink.core.network.media.model.details.season.EpisodeResponse import com.divinelink.core.network.media.model.details.toHourMinuteFormat import com.divinelink.core.network.media.model.details.toPerson -fun EpisodeResponse.map( - accountRating: Int? -) = Episode( +fun EpisodeResponse.map(accountRating: Int?) = Episode( id = id, name = name, airDate = airDate, diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/SubmitOnAccountResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/TMDBResponse.kt similarity index 89% rename from core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/SubmitOnAccountResponse.kt rename to core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/TMDBResponse.kt index 788656a2c..38f8f33f6 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/SubmitOnAccountResponse.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/watchlist/TMDBResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SubmitOnAccountResponse( +data class TMDBResponse( val success: Boolean, @SerialName("status_code") val statusCode: Int, @SerialName("status_message") val statusMessage: String, diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt index ffb7daa16..f268ccd3b 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt @@ -12,7 +12,7 @@ import com.divinelink.core.network.media.model.details.reviews.ReviewsResponseAp import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.find.FindByIdResponseApi import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.rating.AddRatingRequestApi @@ -69,11 +69,11 @@ interface MediaService { request: AccountMediaDetailsRequestApi, ): Flow - suspend fun submitRating(request: AddRatingRequestApi): Result + suspend fun submitRating(request: AddRatingRequestApi): Result - suspend fun deleteRating(request: DeleteRatingRequestApi): Result + suspend fun deleteRating(request: DeleteRatingRequestApi): Result - suspend fun addToWatchlist(request: AddToWatchlistRequestApi): Result + suspend fun addToWatchlist(request: AddToWatchlistRequestApi): Result fun findById(externalId: String): Flow diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index 81b896a36..c311baba4 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -16,7 +16,7 @@ import com.divinelink.core.network.media.model.details.season.SeasonDetailsRespo import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestBodyApi -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.find.FindByIdResponseApi import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.rating.AddRatingRequestApi @@ -202,7 +202,7 @@ class ProdMediaService( emit(response) } - override suspend fun submitRating(request: AddRatingRequestApi): Result = + override suspend fun submitRating(request: AddRatingRequestApi): Result = runCatchingWithNetworkRetry( maxDelay = 1000L, times = 10, @@ -212,44 +212,42 @@ class ProdMediaService( "${request.id}/rating?" + "&session_id=${request.sessionId}" - restClient.post( + restClient.post( url = url, body = AddRatingRequestBodyApi(request.rating), ) } - override suspend fun deleteRating( - request: DeleteRatingRequestApi, - ): Result = runCatchingWithNetworkRetry( - maxDelay = 1000L, - times = 10, - ) { - val baseUrl = "${restClient.tmdbUrl}/${request.endpoint}/" - val url = baseUrl + - "${request.id}/rating?" + - "&session_id=${request.sessionId}" + override suspend fun deleteRating(request: DeleteRatingRequestApi): Result = + runCatchingWithNetworkRetry( + maxDelay = 1000L, + times = 10, + ) { + val baseUrl = "${restClient.tmdbUrl}/${request.endpoint}/" + val url = baseUrl + + "${request.id}/rating?" + + "&session_id=${request.sessionId}" - restClient.delete(url = url) - } + restClient.delete(url = url) + } - override suspend fun addToWatchlist( - request: AddToWatchlistRequestApi, - ): Result = runCatchingWithNetworkRetry( - maxDelay = 1000L, - times = 10, - ) { - val url = "${restClient.tmdbUrl}/account/${request.accountId}/watchlist" + - "?session_id=${request.sessionId}" + override suspend fun addToWatchlist(request: AddToWatchlistRequestApi): Result = + runCatchingWithNetworkRetry( + maxDelay = 1000L, + times = 10, + ) { + val url = "${restClient.tmdbUrl}/account/${request.accountId}/watchlist" + + "?session_id=${request.sessionId}" - restClient.post( - url = url, - body = AddToWatchlistRequestBodyApi( - mediaType = request.mediaType, - mediaId = request.mediaId, - watchlist = request.addToWatchlist, - ), - ) - } + restClient.post( + url = url, + body = AddToWatchlistRequestBodyApi( + mediaType = request.mediaType, + mediaId = request.mediaId, + watchlist = request.addToWatchlist, + ), + ) + } override fun findById(externalId: String): Flow = flow { val url = buildFindByIdUrl(externalId = externalId) @@ -269,7 +267,7 @@ class ProdMediaService( url = buildSeasonDetailsUrl( showId = showId, seasonNumber = season, - sessionId = encryptedStorage.tmdbSessionId + sessionId = encryptedStorage.tmdbSessionId, ), ) } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/model/ValueRequest.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/model/ValueRequest.kt new file mode 100644 index 000000000..85d93ae32 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/model/ValueRequest.kt @@ -0,0 +1,8 @@ +package com.divinelink.core.network.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ValueRequest( + val value: Float, +) diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index add8b985b..931a141bb 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt @@ -83,7 +83,6 @@ sealed interface Navigation { val seasonTitle: String, val seasonNumber: Int, val episodeIndex: Int, -// val availableEpisodes: List, ) : Navigation @Serializable diff --git a/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/service/TestMediaService.kt b/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/service/TestMediaService.kt index 4b4828281..e101572bf 100644 --- a/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/service/TestMediaService.kt +++ b/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/service/TestMediaService.kt @@ -8,7 +8,7 @@ import com.divinelink.core.network.media.model.details.DetailsResponseApi import com.divinelink.core.network.media.model.details.reviews.ReviewsResponseApi import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi -import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse +import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.rating.AddRatingRequestApi import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi @@ -118,7 +118,7 @@ class TestMediaService { suspend fun mockSubmitRating( request: AddRatingRequestApi, - response: Result, + response: Result, ) { whenever( mock.submitRating(request), @@ -129,7 +129,7 @@ class TestMediaService { suspend fun mockDeleteRating( request: DeleteRatingRequestApi, - response: Result, + response: Result, ) { whenever( mock.deleteRating(request), @@ -140,7 +140,7 @@ class TestMediaService { suspend fun mockAddToWatchlist( request: AddToWatchlistRequestApi, - response: Result, + response: Result, ) { whenever( mock.addToWatchlist(request), diff --git a/feature/add-to-account/src/commonMain/composeResources/values/strings.xml b/feature/add-to-account/src/commonMain/composeResources/values/strings.xml index dc37364c4..bbd8f7d83 100644 --- a/feature/add-to-account/src/commonMain/composeResources/values/strings.xml +++ b/feature/add-to-account/src/commonMain/composeResources/values/strings.xml @@ -22,4 +22,8 @@ %1$s?]]> Clear my rating Submit rating + + Please login to submit your rating. + Rating submitted for %1$s + Rating cleared for %1$s \ No newline at end of file diff --git a/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt index 2c51e84a0..2799848af 100644 --- a/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt +++ b/feature/add-to-account/src/commonMain/kotlin/com/divinelink/feature/add/to/account/rate/RateModalBottomSheet.kt @@ -14,7 +14,6 @@ fun RateModalBottomSheet( sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), value: Int?, mediaTitle: String, - canClearRate: Boolean, onSubmitRate: (Int) -> Unit, onClearRate: () -> Unit, onDismissRequest: () -> Unit, @@ -27,12 +26,15 @@ fun RateModalBottomSheet( modifier = modifier, value = value?.toFloat() ?: 0f, mediaTitle = mediaTitle, - onSubmitRate = onSubmitRate, + onSubmitRate = { + onSubmitRate(it) + onDismissRequest() + }, onClearRate = { onClearRate() onDismissRequest() }, - canClearRate = canClearRate, + canClearRate = value != null, ) } } diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index 39948fbc5..1b37c93e8 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -3,11 +3,8 @@ Watchlist Add to list - Rating submitted for %1$s - Rating cleared for %1$s Added %1$s to watchlist Removed %1$s from watchlist - Please login to submit your rating. Please login to add to your watchlist. Genres diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt index faca4e6c8..8941947e3 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsScreen.kt @@ -92,7 +92,6 @@ fun DetailsScreen( }, onClearRate = viewModel::onClearRating, onDismissRequest = { openRateBottomSheet = false }, - canClearRate = viewState.userDetails.rating != null, ) } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt index 3b6777510..41044070f 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt @@ -54,12 +54,12 @@ import com.divinelink.core.network.media.model.MediaRequestApi import com.divinelink.core.ui.UiString import com.divinelink.core.ui.resources.core_ui_error_retry import com.divinelink.core.ui.snackbar.SnackbarMessage +import com.divinelink.feature.add.to.account.resources.must_be_logged_in_to_rate +import com.divinelink.feature.add.to.account.resources.rating_deleted_successfully +import com.divinelink.feature.add.to.account.resources.rating_submitted_successfully import com.divinelink.feature.details.resources.Res import com.divinelink.feature.details.resources.details__added_to_watchlist -import com.divinelink.feature.details.resources.details__must_be_logged_in_to_rate import com.divinelink.feature.details.resources.details__must_be_logged_in_to_watchlist -import com.divinelink.feature.details.resources.details__rating_deleted_successfully -import com.divinelink.feature.details.resources.details__rating_submitted_successfully import com.divinelink.feature.details.resources.details__removed_from_watchlist import com.divinelink.feature.details.resources.feature_details_jellyseerr_failed_request_delete import com.divinelink.feature.details.resources.feature_details_jellyseerr_failure_media_delete @@ -78,6 +78,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import com.divinelink.feature.add.to.account.resources.Res as AccountRes class DetailsViewModel( getMediaDetailsUseCase: GetMediaDetailsUseCase, @@ -392,7 +393,7 @@ class DetailsViewModel( userDetails = viewState.userDetails.copy(rating = rating.toFloat()), snackbarMessage = SnackbarMessage.from( text = UIText.ResourceText( - Res.string.details__rating_submitted_successfully, + AccountRes.string.rating_submitted_successfully, viewState.mediaDetails?.title ?: "", ), ), @@ -405,7 +406,7 @@ class DetailsViewModel( _viewState.update { viewState -> viewState.copy( snackbarMessage = SnackbarMessage.from( - text = UIText.ResourceText(Res.string.details__must_be_logged_in_to_rate), + text = UIText.ResourceText(AccountRes.string.must_be_logged_in_to_rate), actionLabelText = UIText.ResourceText(Res.string.login), onSnackbarResult = ::navigateToLogin, ), @@ -441,7 +442,7 @@ class DetailsViewModel( userDetails = viewState.userDetails.copy(rating = null), snackbarMessage = SnackbarMessage.from( text = UIText.ResourceText( - Res.string.details__rating_deleted_successfully, + AccountRes.string.rating_deleted_successfully, viewState.mediaDetails?.title ?: "", ), ), diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt index 028d2a022..bb4e59322 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt @@ -2,4 +2,8 @@ package com.divinelink.feature.episode sealed interface EpisodeAction { data class OnSelectEpisode(val index: Int) : EpisodeAction + data class OnSubmitRate(val rate: Int) : EpisodeAction + data object OnClearRate : EpisodeAction + + data object DismissSnackbar : EpisodeAction } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt index 9ec9f18ee..a11a421c0 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt @@ -3,6 +3,7 @@ package com.divinelink.feature.episode import com.divinelink.core.model.details.Episode import com.divinelink.core.model.tab.EpisodeTab import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.snackbar.SnackbarMessage data class EpisodeUiState( val showId: Int, @@ -12,6 +13,8 @@ data class EpisodeUiState( val selectedIndex: Int, val episodes: Map, val tabs: List, + val snackbarMessage: SnackbarMessage?, + val submitLoading: Boolean, ) { val episode by lazy { episodes[selectedIndex] } @@ -24,6 +27,8 @@ data class EpisodeUiState( selectedIndex = route.episodeIndex, episodes = emptyMap(), tabs = emptyList(), + snackbarMessage = null, + submitLoading = false, ) } } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt index ee3870755..ba1855f87 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt @@ -1,21 +1,39 @@ package com.divinelink.feature.episode +import androidx.annotation.VisibleForTesting +import androidx.compose.material3.SnackbarResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.divinelink.core.commons.data +import com.divinelink.core.data.account.AccountRepository import com.divinelink.core.data.media.repository.MediaRepository +import com.divinelink.core.model.UIText +import com.divinelink.core.model.exception.SessionException import com.divinelink.core.model.tab.EpisodeTab import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.resources.core_ui_error_retry +import com.divinelink.core.ui.resources.core_ui_login +import com.divinelink.core.ui.snackbar.SnackbarMessage +import com.divinelink.feature.add.to.account.resources.must_be_logged_in_to_rate +import com.divinelink.feature.add.to.account.resources.rating_deleted_successfully +import com.divinelink.feature.add.to.account.resources.rating_submitted_successfully +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import com.divinelink.feature.add.to.account.resources.Res as AccountRes class EpisodeViewModel( private val repository: MediaRepository, + private val accountRepository: AccountRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -32,6 +50,9 @@ class EpisodeViewModel( ) val uiState: StateFlow = _uiState + private val _navigateToLogin = Channel() + val navigateToLogin: Flow = _navigateToLogin.receiveAsFlow() + init { repository .getSeasonEpisodesNumber( @@ -75,6 +96,9 @@ class EpisodeViewModel( fun onAction(action: EpisodeAction) { when (action) { is EpisodeAction.OnSelectEpisode -> handleOnSelectEpisode(action) + EpisodeAction.OnClearRate -> handleOnClearRate() + is EpisodeAction.OnSubmitRate -> handleOnSubmitRate(action) + EpisodeAction.DismissSnackbar -> handleDismissSnackbar() } } @@ -85,4 +109,92 @@ class EpisodeViewModel( fetchEpisode() } + + private fun handleOnClearRate() { + SnackbarMessage.from( + text = UIText.ResourceText( + AccountRes.string.rating_deleted_successfully, + uiState.value.episode?.name ?: "", + ), + ) + } + + private fun handleOnSubmitRate(action: EpisodeAction.OnSubmitRate) { + viewModelScope.launch { + val showId = uiState.value.showId + val season = uiState.value.seasonNumber + val number = uiState.value.episode?.number ?: -1 + val rating = action.rate + + _uiState.update { it.copy(submitLoading = true) } + + accountRepository.submitEpisodeRating( + showId = showId, + season = season, + number = number, + rating = rating, + ) + .fold( + onSuccess = { + _uiState.update { uiState -> + uiState.copy( + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText( + AccountRes.string.rating_submitted_successfully, + uiState.episode?.name ?: "", + ), + ), + submitLoading = false, + ) + } + + repository.insertEpisodeRating( + showId = showId, + season = season, + number = number, + rating = rating, + ) + }, + onFailure = { error -> + val snackbarMessage = if (error is SessionException.Unauthenticated) { + SnackbarMessage.from( + text = UIText.ResourceText(AccountRes.string.must_be_logged_in_to_rate), + actionLabelText = UIText.ResourceText(UiString.core_ui_login), + onSnackbarResult = ::navigateToLogin, + ) + } else { + SnackbarMessage.from(text = UIText.ResourceText(UiString.core_ui_error_retry)) + } + + _uiState.update { uiState -> + uiState.copy( + snackbarMessage = snackbarMessage, + submitLoading = false, + ) + } + }, + ) + } + } + + private fun handleDismissSnackbar() { + _uiState.update { uiState -> + uiState.copy(snackbarMessage = null) + } + } + + @VisibleForTesting + fun navigateToLogin(snackbarResult: SnackbarResult) { + if (snackbarResult == SnackbarResult.ActionPerformed) { + _uiState.update { viewState -> + viewState.copy( + snackbarMessage = null, + ) + } + + viewModelScope.launch { + _navigateToLogin.send(Unit) + } + } + } } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt index 54e5face5..73087db69 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt @@ -92,11 +92,10 @@ fun SharedTransitionScope.EpisodeContent( verticalArrangement = Arrangement.SpaceEvenly, ) { EpisodeTitleDetails( + uiState = uiState, + episode = episode, onNavigate = onNavigate, action = action, - title = uiState.showTitle, - season = uiState.seasonTitle, - episode = episode, ) } }, diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt index dfb7cd94f..b1393f3aa 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -27,6 +28,8 @@ import com.divinelink.core.scaffold.PersistentNavigationRail import com.divinelink.core.scaffold.PersistentScaffold import com.divinelink.core.scaffold.rememberScaffoldState import com.divinelink.core.ui.components.AppTopAppBar +import com.divinelink.core.ui.snackbar.SnackbarMessageHandler +import com.divinelink.feature.episode.EpisodeAction import com.divinelink.feature.episode.EpisodeViewModel import org.koin.compose.viewmodel.koinViewModel @@ -59,6 +62,11 @@ fun AnimatedVisibilityScope.EpisodeScreen( } } + SnackbarMessageHandler( + snackbarMessage = uiState.snackbarMessage, + onDismissSnackbar = { viewModel.onAction(EpisodeAction.DismissSnackbar) }, + ) + val surfaceColor = MaterialTheme.colorScheme.surface DisposableEffect(textColor) { val isLight = textColor == surfaceColor @@ -68,6 +76,12 @@ fun AnimatedVisibilityScope.EpisodeScreen( } } + LaunchedEffect(Unit) { + viewModel.navigateToLogin.collect { + onNavigate(Navigation.TMDBAuthRoute) + } + } + rememberScaffoldState( animatedVisibilityScope = this, ).PersistentScaffold( diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt index e7a8b538d..c8841ed76 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt @@ -25,16 +25,16 @@ import com.divinelink.core.ui.components.details.AirDateWithRuntime import com.divinelink.core.ui.rating.MediaRatingItem import com.divinelink.feature.add.to.account.rate.RateModalBottomSheet import com.divinelink.feature.episode.EpisodeAction +import com.divinelink.feature.episode.EpisodeUiState @OptIn(ExperimentalMaterial3Api::class) @Composable fun EpisodeTitleDetails( modifier: Modifier = Modifier, + uiState: EpisodeUiState, + episode: Episode, onNavigate: (Navigation) -> Unit, action: (EpisodeAction) -> Unit, - title: String, - season: String, - episode: Episode, ) { var showRateModal by rememberSaveable { mutableStateOf(false) } @@ -43,11 +43,8 @@ fun EpisodeTitleDetails( modifier = modifier, value = episode.accountRating, mediaTitle = episode.name, - canClearRate = true, - onSubmitRate = { - }, - onClearRate = { - }, + onSubmitRate = { action(EpisodeAction.OnSubmitRate(it)) }, + onClearRate = { action(EpisodeAction.OnClearRate) }, onDismissRequest = { showRateModal = false }, ) } @@ -61,7 +58,7 @@ fun EpisodeTitleDetails( modifier = Modifier.clickable { onNavigate(Navigation.TwiceBack) }, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleSmall, - text = title, + text = uiState.showTitle, ) Text( @@ -74,7 +71,7 @@ fun EpisodeTitleDetails( modifier = Modifier.clickable { onNavigate(Navigation.Back) }, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleSmall, - text = season, + text = uiState.seasonTitle, ) } @@ -89,26 +86,28 @@ fun EpisodeTitleDetails( style = MaterialTheme.typography.titleSmall, ) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - MediaRatingItem( - ratingDetails = RatingDetails.Score( - voteAverage = episode.voteAverage?.toDouble() ?: 0.0, - voteCount = episode.voteCount ?: 0, - ), - source = RatingSource.TMDB, - ) + if (uiState.seasonNumber != 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MediaRatingItem( + ratingDetails = RatingDetails.Score( + voteAverage = episode.voteAverage?.toDouble() ?: 0.0, + voteCount = episode.voteCount ?: 0, + ), + source = RatingSource.TMDB, + ) - Spacer( - modifier = Modifier.weight(1f) - ) + Spacer( + modifier = Modifier.weight(1f), + ) - RatingButton( - accountRating = episode.accountRating, - onClick = { showRateModal = true }, - isLoading = false, - ) + RatingButton( + accountRating = episode.accountRating, + onClick = { showRateModal = true }, + isLoading = uiState.submitLoading, + ) + } } } } From 1b3d2ab46a60ed31955b51909e059b40912c6694 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 20:39:10 +0200 Subject: [PATCH 5/8] feat: implement clear episode rating --- .../core/data/account/AccountRepository.kt | 7 ++- .../data/account/ProdAccountRepository.kt | 22 +++++++-- .../data/media/repository/MediaRepository.kt | 2 + .../media/repository/ProdMediaRepository.kt | 4 ++ .../core/database/media/dao/MediaDao.kt | 8 +++ .../core/database/media/dao/ProdMediaDao.kt | 37 +++++++++----- .../database/season/EpisodeRatingEntity.sq | 5 +- .../network/account/service/AccountService.kt | 6 +++ .../account/service/ProdAccountService.kt | 23 ++++++++- .../core/network/account/util/BuildUrl.kt | 2 +- .../feature/episode/EpisodeUiState.kt | 4 +- .../feature/episode/EpisodeViewModel.kt | 49 +++++++++++++++---- .../feature/episode/ui/EpisodeTitleDetails.kt | 2 +- 13 files changed, 139 insertions(+), 32 deletions(-) diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt index 4cca3725d..53965d227 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/AccountRepository.kt @@ -2,7 +2,6 @@ package com.divinelink.core.data.account import com.divinelink.core.model.PaginationData import com.divinelink.core.model.media.MediaItem -import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import kotlinx.coroutines.flow.Flow interface AccountRepository { @@ -42,5 +41,9 @@ interface AccountRepository { rating: Int, ): Result - suspend fun deleteEpisodeRating(request: DeleteRatingRequestApi): Result + suspend fun deleteEpisodeRating( + showId: Int, + season: Int, + number: Int, + ): Result } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt index e9b77e668..c39a2dfd7 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/account/ProdAccountRepository.kt @@ -7,7 +7,6 @@ import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.account.service.AccountService import com.divinelink.core.network.media.model.movie.map -import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import com.divinelink.core.network.media.model.tv.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -121,8 +120,25 @@ class ProdAccountRepository( Result.success(data.copy(list = updatedTvShows)) } - override suspend fun deleteEpisodeRating(request: DeleteRatingRequestApi): Result { - TODO("Not yet implemented") + override suspend fun deleteEpisodeRating( + showId: Int, + season: Int, + number: Int, + ): Result = remote.clearEpisodeRating( + showId = showId, + season = season, + number = number, + ).map { response -> + if (response.success) { + dao.deleteEpisodeRating( + showId = showId, + season = season, + number = number, + ) + Result.success(Unit) + } else { + Result.failure(AppException.Unknown()) + } } override suspend fun submitEpisodeRating( diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt index 0a01b1324..047498fbb 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt @@ -117,4 +117,6 @@ interface MediaRepository { number: Int, rating: Int, ): Result + + fun clearAllEpisodeRatings(): Result } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index 703bbb398..72625d994 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -393,4 +393,8 @@ class ProdMediaRepository( rating = rating, ) } + + override fun clearAllEpisodeRatings(): Result = runCatching { + dao.clearAllEpisodeRatings() + } } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt index 142035eab..9e54e9e77 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt @@ -108,4 +108,12 @@ interface MediaDao { number: Int, rating: Int, ) + + fun deleteEpisodeRating( + showId: Int, + season: Int, + number: Int, + ) + + fun clearAllEpisodeRatings() } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt index a32339b90..4548b91ff 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt @@ -455,15 +455,30 @@ class ProdMediaDao( season: Int, number: Int, rating: Int, - ) = database - .transaction { - database.episodeRatingEntityQueries.insertEpisodeRating( - EpisodeRatingEntity( - number = number.toLong(), - showId = showId.toLong(), - season = season.toLong(), - rating = rating.toLong(), - ), - ) - } + ) = database.transaction { + database.episodeRatingEntityQueries.insertEpisodeRating( + EpisodeRatingEntity( + number = number.toLong(), + showId = showId.toLong(), + season = season.toLong(), + rating = rating.toLong(), + ), + ) + } + + override fun deleteEpisodeRating( + showId: Int, + season: Int, + number: Int, + ) = database.transaction { + database.episodeRatingEntityQueries.deleteEpisodeRating( + number = number.toLong(), + showId = showId.toLong(), + season = season.toLong(), + ) + } + + override fun clearAllEpisodeRatings() = database.transaction { + database.episodeRatingEntityQueries.clearAllEpisodeRatings() + } } diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq index 4cdd14ccd..5c4a9aa5a 100644 --- a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeRatingEntity.sq @@ -24,5 +24,8 @@ SELECT * FROM EpisodeRatingEntity WHERE showId = ? AND season = ? ORDER BY number; -deleteListMediaItem: +deleteEpisodeRating: DELETE FROM EpisodeRatingEntity WHERE showId = ? AND season = ? AND number = ?; + +clearAllEpisodeRatings: +DELETE FROM EpisodeRatingEntity; diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt index 4ec4453c8..b1ecbd9ea 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/AccountService.kt @@ -41,4 +41,10 @@ interface AccountService { number: Int, rating: Int, ): Result + + suspend fun clearEpisodeRating( + showId: Int, + season: Int, + number: Int, + ): Result } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt index 107a5ae97..ae6f5e536 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/service/ProdAccountService.kt @@ -3,7 +3,7 @@ package com.divinelink.core.network.account.service import com.divinelink.core.datastore.auth.SavedStateStorage import com.divinelink.core.datastore.auth.tmdbSessionId import com.divinelink.core.model.exception.SessionException -import com.divinelink.core.network.account.util.buildSubmitEpisodeRating +import com.divinelink.core.network.account.util.buildEpisodeRatingUrl import com.divinelink.core.network.client.TMDbClient import com.divinelink.core.network.media.model.details.watchlist.TMDBResponse import com.divinelink.core.network.media.model.movie.MoviesResponseApi @@ -90,7 +90,7 @@ class ProdAccountService( if (storage.tmdbSessionId == null) { throw SessionException.Unauthenticated() } else { - val url = buildSubmitEpisodeRating( + val url = buildEpisodeRatingUrl( showId = showId, season = season, number = number, @@ -103,4 +103,23 @@ class ProdAccountService( ) } } + + override suspend fun clearEpisodeRating( + showId: Int, + season: Int, + number: Int, + ): Result = runCatching { + if (storage.tmdbSessionId == null) { + throw SessionException.Unauthenticated() + } else { + val url = buildEpisodeRatingUrl( + showId = showId, + season = season, + number = number, + sessionId = storage.tmdbSessionId!!, + ) + + restClient.delete(url = url) + } + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt index 97285ac77..f58ac3ea1 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/account/util/BuildUrl.kt @@ -19,7 +19,7 @@ internal fun buildFetchListsUrl( } }.toString() -internal fun buildSubmitEpisodeRating( +internal fun buildEpisodeRatingUrl( showId: Int, season: Int, number: Int, diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt index a11a421c0..0ea954027 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt @@ -14,7 +14,7 @@ data class EpisodeUiState( val episodes: Map, val tabs: List, val snackbarMessage: SnackbarMessage?, - val submitLoading: Boolean, + val ratingLoading: Boolean, ) { val episode by lazy { episodes[selectedIndex] } @@ -28,7 +28,7 @@ data class EpisodeUiState( episodes = emptyMap(), tabs = emptyList(), snackbarMessage = null, - submitLoading = false, + ratingLoading = false, ) } } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt index ba1855f87..06df8f4bb 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt @@ -111,12 +111,43 @@ class EpisodeViewModel( } private fun handleOnClearRate() { - SnackbarMessage.from( - text = UIText.ResourceText( - AccountRes.string.rating_deleted_successfully, - uiState.value.episode?.name ?: "", - ), - ) + viewModelScope.launch { + val showId = uiState.value.showId + val season = uiState.value.seasonNumber + val number = uiState.value.episode?.number ?: -1 + + _uiState.update { it.copy(ratingLoading = true) } + + accountRepository.deleteEpisodeRating( + showId = showId, + season = season, + number = number, + ).fold( + onSuccess = { + _uiState.update { uiState -> + uiState.copy( + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText( + AccountRes.string.rating_deleted_successfully, + uiState.episode?.name ?: "", + ), + ), + ratingLoading = false, + ) + } + }, + onFailure = { + _uiState.update { uiState -> + uiState.copy( + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText(UiString.core_ui_error_retry), + ), + ratingLoading = false, + ) + } + }, + ) + } } private fun handleOnSubmitRate(action: EpisodeAction.OnSubmitRate) { @@ -126,7 +157,7 @@ class EpisodeViewModel( val number = uiState.value.episode?.number ?: -1 val rating = action.rate - _uiState.update { it.copy(submitLoading = true) } + _uiState.update { it.copy(ratingLoading = true) } accountRepository.submitEpisodeRating( showId = showId, @@ -144,7 +175,7 @@ class EpisodeViewModel( uiState.episode?.name ?: "", ), ), - submitLoading = false, + ratingLoading = false, ) } @@ -169,7 +200,7 @@ class EpisodeViewModel( _uiState.update { uiState -> uiState.copy( snackbarMessage = snackbarMessage, - submitLoading = false, + ratingLoading = false, ) } }, diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt index c8841ed76..721a4d958 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt @@ -105,7 +105,7 @@ fun EpisodeTitleDetails( RatingButton( accountRating = episode.accountRating, onClick = { showRateModal = true }, - isLoading = uiState.submitLoading, + isLoading = uiState.ratingLoading, ) } } From d190575b727cebe2ed50c9afebe383b9b5b6ab75 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 20:41:08 +0200 Subject: [PATCH 6/8] feat: clear episode ratings upon logout --- .../com/divinelink/core/data/auth/ProdAuthRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt index 84316ddad..e6fe7538e 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/auth/ProdAuthRepository.kt @@ -1,5 +1,6 @@ package com.divinelink.core.data.auth +import com.divinelink.core.database.media.dao.MediaDao import com.divinelink.core.datastore.auth.SavedState import com.divinelink.core.datastore.auth.SavedStateStorage import com.divinelink.core.datastore.auth.isJellyseerrEnabled @@ -12,7 +13,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -class ProdAuthRepository(private val savedStateStorage: SavedStateStorage) : AuthRepository { +class ProdAuthRepository( + private val savedStateStorage: SavedStateStorage, + private val mediaDao: MediaDao, +) : AuthRepository { override val isJellyseerrEnabled: Flow = savedStateStorage .savedState @@ -67,7 +71,7 @@ class ProdAuthRepository(private val savedStateStorage: SavedStateStorage) : Aut } override suspend fun clearTMDBSession() { - // TODO Clear saved episode ratings data + mediaDao.clearAllEpisodeRatings() savedStateStorage.clearTMDBSession() } } From 0068c11e10973e75985f2d88fe02e906f2d4bbf0 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 7 Feb 2026 20:52:13 +0200 Subject: [PATCH 7/8] feat: properly handle switch between episodes --- .../feature/episode/EpisodeUiState.kt | 4 +- .../feature/episode/EpisodeViewModel.kt | 38 +++++++++++-------- .../feature/episode/ui/EpisodeContent.kt | 6 +-- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt index 0ea954027..0820ccdc0 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt @@ -11,13 +11,12 @@ data class EpisodeUiState( val seasonTitle: String, val seasonNumber: Int, val selectedIndex: Int, + val episode: Episode?, val episodes: Map, val tabs: List, val snackbarMessage: SnackbarMessage?, val ratingLoading: Boolean, ) { - val episode by lazy { episodes[selectedIndex] } - companion object { fun initial(route: Navigation.EpisodeRoute) = EpisodeUiState( showId = route.showId, @@ -27,6 +26,7 @@ data class EpisodeUiState( selectedIndex = route.episodeIndex, episodes = emptyMap(), tabs = emptyList(), + episode = null, snackbarMessage = null, ratingLoading = false, ) diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt index 06df8f4bb..e16faa5e4 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt @@ -74,23 +74,31 @@ class EpisodeViewModel( } private fun fetchEpisode() { - // TODO add check if episode does not exist to fetch it from DB - repository.fetchEpisode( - showId = uiState.value.showId, - season = uiState.value.seasonNumber, - number = uiState.value.tabs[uiState.value.selectedIndex].number, - ) - .distinctUntilChanged() - .onEach { - _uiState.update { uiState -> - val episode = it.data - - uiState.copy( - episodes = uiState.episodes.plus(uiState.selectedIndex to episode), - ) + val cachedEpisode = uiState.value.episodes[uiState.value.selectedIndex] + + if (cachedEpisode == null) { + repository.fetchEpisode( + showId = uiState.value.showId, + season = uiState.value.seasonNumber, + number = uiState.value.tabs[uiState.value.selectedIndex].number, + ) + .distinctUntilChanged() + .onEach { + _uiState.update { uiState -> + val episode = it.data + + uiState.copy( + episode = episode, + episodes = uiState.episodes.plus(uiState.selectedIndex to episode), + ) + } } + .launchIn(viewModelScope) + } else { + _uiState.update { uiState -> + uiState.copy(episode = cachedEpisode) } - .launchIn(viewModelScope) + } } fun onAction(action: EpisodeAction) { diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt index 73087db69..1a117be11 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt @@ -61,7 +61,7 @@ fun SharedTransitionScope.EpisodeContent( onNavigate: (Navigation) -> Unit, action: (EpisodeAction) -> Unit, ) { - val episode = uiState.episode ?: return + uiState.episode ?: return val scope = rememberCoroutineScope() @@ -81,7 +81,7 @@ fun SharedTransitionScope.EpisodeContent( DetailCollapsibleContent( visibilityScope = visibilityScope, - backdropPath = episode.stillPath, + backdropPath = uiState.episode.stillPath, posterPath = null, toolbarProgress = toolbarProgress, onBackdropLoaded = onBackdropLoaded, @@ -93,7 +93,7 @@ fun SharedTransitionScope.EpisodeContent( ) { EpisodeTitleDetails( uiState = uiState, - episode = episode, + episode = uiState.episode, onNavigate = onNavigate, action = action, ) From fbd2420b94d30ec2ce68cee155f65225f7d9e13c Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 8 Feb 2026 12:36:52 +0200 Subject: [PATCH 8/8] test: validate unit tests --- .../core/data/auth/ProdAuthRepositoryTest.kt | 68 ++++++++++++++++--- .../media/repository/ProdMediaRepository.kt | 2 - .../network/account/ProdAccountServiceTest.kt | 24 +++++-- .../media/service/ProdMediaServiceTest.kt | 39 +++++++++-- .../network/media/service/ProdMediaService.kt | 4 +- .../core/testing/dao/TestMediaDao.kt | 4 ++ .../feature/details/DetailsContentTest.kt | 6 +- .../feature/details/DetailsScreenTest.kt | 9 +-- .../feature/details/DetailsViewModelTest.kt | 17 ++--- 9 files changed, 134 insertions(+), 39 deletions(-) diff --git a/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/auth/ProdAuthRepositoryTest.kt b/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/auth/ProdAuthRepositoryTest.kt index dcb9ff728..03d9ada19 100644 --- a/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/auth/ProdAuthRepositoryTest.kt +++ b/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/auth/ProdAuthRepositoryTest.kt @@ -6,21 +6,32 @@ import com.divinelink.core.datastore.auth.observedTmdbSession import com.divinelink.core.fixtures.model.account.AccountDetailsFactory import com.divinelink.core.fixtures.model.jellyseerr.JellyseerrProfileFactory import com.divinelink.core.fixtures.model.session.TmdbSessionFactory +import com.divinelink.core.testing.dao.TestMediaDao import com.divinelink.core.testing.factories.datastore.auth.JellyseerrAccountFactory import com.divinelink.core.testing.storage.TestSavedStateStorage import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest import kotlin.test.Test class ProdAuthRepositoryTest { private lateinit var savedStateStorage: TestSavedStateStorage private lateinit var repository: ProdAuthRepository + private lateinit var mediaDao: TestMediaDao + + @BeforeTest + fun setup() { + mediaDao = TestMediaDao() + } @Test fun `test isJellyseerrEnabled is true when accountIds are not empty`() = runTest { savedStateStorage = TestSavedStateStorage() - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) repository.isJellyseerrEnabled.test { awaitItem() shouldBe false @@ -38,7 +49,10 @@ class ProdAuthRepositoryTest { @Test fun `test jellyseerrAccounts`() = runTest { savedStateStorage = TestSavedStateStorage() - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) repository.jellyseerrCredentials.test { awaitItem() shouldBe emptyMap() @@ -60,7 +74,10 @@ class ProdAuthRepositoryTest { @Test fun `test updateJellyseerrCredentials sets jellyseerrCredentials`() = runTest { savedStateStorage = TestSavedStateStorage() - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) savedStateStorage.savedState.value.jellyseerrCredentials shouldBe emptyMap() @@ -74,7 +91,10 @@ class ProdAuthRepositoryTest { @Test fun `test updateJellyseerrProfile sets jellyseerrProfile`() = runTest { savedStateStorage = TestSavedStateStorage() - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) savedStateStorage.savedState.value.jellyseerrCredentials shouldBe emptyMap() @@ -102,7 +122,10 @@ class ProdAuthRepositoryTest { ), selectedJellyseerrAccountId = "account_1", ) - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) savedStateStorage.savedState.value.jellyseerrCredentials shouldBe mapOf( "account_1" to JellyseerrAccountFactory.cup10(), @@ -139,7 +162,10 @@ class ProdAuthRepositoryTest { savedStateStorage = TestSavedStateStorage( tmdbAccount = null, ) - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) repository.tmdbAccount.test { awaitItem() shouldBe null @@ -155,7 +181,10 @@ class ProdAuthRepositoryTest { savedStateStorage = TestSavedStateStorage( tmdbAccount = null, ) - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) savedStateStorage.observedTmdbSession.test { awaitItem() shouldBe null @@ -172,7 +201,10 @@ class ProdAuthRepositoryTest { tmdbAccount = AccountDetailsFactory.Pinkman(), tmdbSession = TmdbSessionFactory.full(), ) - repository = ProdAuthRepository(savedStateStorage) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) savedStateStorage.savedState.test { awaitItem() shouldBe InitialSavedState.copy( @@ -185,4 +217,24 @@ class ProdAuthRepositoryTest { awaitItem() shouldBe InitialSavedState } } + + @Test + fun `test clear tmdb session also clears episode ratings`() = runTest { + savedStateStorage = TestSavedStateStorage( + tmdbAccount = AccountDetailsFactory.Pinkman(), + tmdbSession = TmdbSessionFactory.full(), + ) + repository = ProdAuthRepository( + savedStateStorage = savedStateStorage, + mediaDao = mediaDao.mock, + ) + + savedStateStorage.savedState.test { + awaitItem() + repository.clearTMDBSession() + awaitItem() + + mediaDao.verifyClearAllEpisodeRatings() + } + } } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index 72625d994..5d451f923 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -2,7 +2,6 @@ package com.divinelink.core.data.media.repository import com.divinelink.core.commons.data import com.divinelink.core.commons.domain.DispatcherProvider -import com.divinelink.core.database.Database import com.divinelink.core.database.media.dao.MediaDao import com.divinelink.core.database.media.mapper.map import com.divinelink.core.database.person.PersonDao @@ -40,7 +39,6 @@ import kotlinx.coroutines.withContext class ProdMediaRepository( private val remote: MediaService, private val dao: MediaDao, - private val database: Database, private val personDao: PersonDao, private val dispatcher: DispatcherProvider, ) : MediaRepository { diff --git a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/account/ProdAccountServiceTest.kt b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/account/ProdAccountServiceTest.kt index b3734fef8..aa970e191 100644 --- a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/account/ProdAccountServiceTest.kt +++ b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/account/ProdAccountServiceTest.kt @@ -1,12 +1,14 @@ package com.divinelink.core.network.account import app.cash.turbine.test +import com.divinelink.core.datastore.auth.SavedStateStorage import com.divinelink.core.network.account.service.ProdAccountService import com.divinelink.core.network.media.model.movie.MovieResponseApi import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.tv.TvItemApi import com.divinelink.core.network.media.model.tv.TvResponseApi import com.divinelink.core.testing.network.TestRestClient +import com.divinelink.core.testing.storage.TestSavedStateStorage import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -16,10 +18,12 @@ class ProdAccountServiceTest { private lateinit var service: ProdAccountService private lateinit var testRestClient: TestRestClient + private lateinit var storage: SavedStateStorage @BeforeTest fun setUp() { testRestClient = TestRestClient() + storage = TestSavedStateStorage() } @Test @@ -32,7 +36,10 @@ class ProdAccountServiceTest { jsonFileName = "watchlist-movie.json", ) - service = ProdAccountService(testRestClient.restClient) + service = ProdAccountService( + restClient = testRestClient.restClient, + storage = storage, + ) service.fetchMoviesWatchlist( page = 1, @@ -87,7 +94,10 @@ class ProdAccountServiceTest { jsonFileName = "watchlist-tv.json", ) - service = ProdAccountService(testRestClient.restClient) + service = ProdAccountService( + restClient = testRestClient.restClient, + storage = storage, + ) service.fetchTvShowsWatchlist( page = 1, @@ -138,7 +148,10 @@ class ProdAccountServiceTest { jsonFileName = "rated-tv.json", ) - service = ProdAccountService(testRestClient.restClient) + service = ProdAccountService( + restClient = testRestClient.restClient, + storage = storage, + ) service.fetchRatedTvShows( page = 1, @@ -190,7 +203,10 @@ class ProdAccountServiceTest { jsonFileName = "rated-movies.json", ) - service = ProdAccountService(testRestClient.restClient) + service = ProdAccountService( + restClient = testRestClient.restClient, + storage = storage, + ) service.fetchRatedMovies( page = 1, diff --git a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt index dd4349472..51d88c76c 100644 --- a/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt +++ b/core/network/src/androidHostTest/kotlin/com/divinelink/core/network/media/service/ProdMediaServiceTest.kt @@ -1,5 +1,6 @@ package com.divinelink.core.network.media.service +import com.divinelink.core.datastore.auth.SavedStateStorage import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi @@ -9,6 +10,7 @@ import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi import com.divinelink.core.testing.factories.api.media.GenreResponseFactory import com.divinelink.core.testing.factories.json.model.GenreListResponseJsonFactory import com.divinelink.core.testing.network.TestRestClient +import com.divinelink.core.testing.storage.TestSavedStateStorage import com.google.common.truth.Truth.assertThat import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest @@ -19,10 +21,12 @@ class ProdMediaServiceTest { private lateinit var service: ProdMediaService private lateinit var testRestClient: TestRestClient + private lateinit var storage: SavedStateStorage @BeforeTest fun setUp() { testRestClient = TestRestClient() + storage = TestSavedStateStorage() } @Test @@ -38,7 +42,10 @@ class ProdMediaServiceTest { """.trimIndent(), ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.deleteRating( DeleteRatingRequestApi.Movie( @@ -71,7 +78,10 @@ class ProdMediaServiceTest { """.trimIndent(), ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.deleteRating( DeleteRatingRequestApi.Movie( @@ -100,7 +110,10 @@ class ProdMediaServiceTest { """.trimIndent(), ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.submitRating( AddRatingRequestApi.Movie( @@ -130,7 +143,10 @@ class ProdMediaServiceTest { """.trimIndent(), ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.submitRating( AddRatingRequestApi.Movie( @@ -164,7 +180,10 @@ class ProdMediaServiceTest { """.trimIndent(), ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.addToWatchlist( AddToWatchlistRequestApi.Movie( @@ -193,7 +212,10 @@ class ProdMediaServiceTest { json = GenreListResponseJsonFactory.movieGenreJson, ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.fetchGenres(MediaType.MOVIE) @@ -209,7 +231,10 @@ class ProdMediaServiceTest { json = GenreListResponseJsonFactory.tvGenreJson, ) - service = ProdMediaService(testRestClient.restClient) + service = ProdMediaService( + restClient = testRestClient.restClient, + storage = storage, + ) val result = service.fetchGenres(MediaType.TV) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index c311baba4..1c4badf84 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.flow class ProdMediaService( private val restClient: TMDbClient, - private val encryptedStorage: SavedStateStorage, + private val storage: SavedStateStorage, ) : MediaService { override suspend fun fetchMediaLists( @@ -267,7 +267,7 @@ class ProdMediaService( url = buildSeasonDetailsUrl( showId = showId, seasonNumber = season, - sessionId = encryptedStorage.tmdbSessionId, + sessionId = storage.tmdbSessionId, ), ) } diff --git a/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/dao/TestMediaDao.kt b/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/dao/TestMediaDao.kt index 4742aa6ae..181e53bd1 100644 --- a/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/dao/TestMediaDao.kt +++ b/core/testing/src/androidMain/kotlin/com/divinelink/core/testing/dao/TestMediaDao.kt @@ -96,6 +96,10 @@ class TestMediaDao { ) } + fun verifyClearAllEpisodeRatings() { + verify(mock).clearAllEpisodeRatings() + } + fun mockCheckIfFavorite( id: Int, mediaType: MediaType, diff --git a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsContentTest.kt b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsContentTest.kt index f0955b70f..e59a5be15 100644 --- a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsContentTest.kt +++ b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsContentTest.kt @@ -52,6 +52,7 @@ import com.divinelink.core.testing.usecase.TestGetServerInstancesUseCase import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.TestTags.LOADING_CONTENT import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.resources.core_ui_add_rating import com.divinelink.core.ui.resources.core_ui_add_to_watchlist_content_desc import com.divinelink.core.ui.resources.core_ui_hide_total_episodes_item import com.divinelink.core.ui.resources.core_ui_mark_as_favorite_button_content_description @@ -63,7 +64,6 @@ import com.divinelink.core.ui.resources.core_ui_tmdb_user_score import com.divinelink.feature.details.media.ui.DetailsContent import com.divinelink.feature.details.media.ui.DetailsViewState import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating import com.divinelink.feature.details.resources.feature_details_request import com.divinelink.feature.request.media.RequestMediaEntryData import com.divinelink.feature.request.media.RequestMediaViewModel @@ -400,10 +400,8 @@ class DetailsContentTest : ComposeTest() { ) } - val addYourRate = getString(Res.string.details__add_rating) - onAllNodesWithText( - text = addYourRate, + text = getString(UiString.core_ui_add_rating), useUnmergedTree = true, ) .onLast() diff --git a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt index a6b9c988d..ce5ca73e2 100644 --- a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt +++ b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt @@ -67,14 +67,14 @@ import com.divinelink.core.testing.usecase.TestMarkAsFavoriteUseCase import com.divinelink.core.testing.usecase.TestSpoilersObfuscationUseCase import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.resources.core_ui_add_rating import com.divinelink.core.ui.resources.core_ui_delete import com.divinelink.core.ui.resources.core_ui_navigate_up_button_content_description import com.divinelink.core.ui.resources.core_ui_view_all +import com.divinelink.feature.add.to.account.resources.submit_rating_button import com.divinelink.feature.details.media.ui.DetailsScreen import com.divinelink.feature.details.media.ui.DetailsViewModel import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.details__add_rating -import com.divinelink.feature.details.resources.details__submit_rating_button import com.divinelink.feature.details.resources.feature_details_manage_movie import com.divinelink.feature.details.resources.feature_details_manage_tv import com.divinelink.feature.details.resources.feature_details_request @@ -92,6 +92,7 @@ import org.koin.test.mock.declare import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import com.divinelink.feature.add.to.account.resources.Res as AccountRes class DetailsScreenTest : ComposeTest() { @@ -530,7 +531,7 @@ class DetailsScreenTest : ComposeTest() { ) } - val addRatingText = getString(Res.string.details__add_rating) + val addRatingText = getString(UiString.core_ui_add_rating) onAllNodesWithTag(TestTags.Details.YOUR_RATING, useUnmergedTree = true) .onFirst() @@ -557,7 +558,7 @@ class DetailsScreenTest : ComposeTest() { swipeRight() } - val submitRatingText = getString(Res.string.details__submit_rating_button) + val submitRatingText = getString(AccountRes.string.submit_rating_button) onNodeWithText(submitRatingText).performClick() diff --git a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsViewModelTest.kt b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsViewModelTest.kt index 180571a96..b1ee9af17 100644 --- a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsViewModelTest.kt +++ b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsViewModelTest.kt @@ -38,13 +38,13 @@ import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFa import com.divinelink.core.ui.UiString import com.divinelink.core.ui.resources.core_ui_error_retry import com.divinelink.core.ui.snackbar.SnackbarMessage +import com.divinelink.feature.add.to.account.resources.must_be_logged_in_to_rate +import com.divinelink.feature.add.to.account.resources.rating_deleted_successfully +import com.divinelink.feature.add.to.account.resources.rating_submitted_successfully import com.divinelink.feature.details.media.ui.DetailsViewModel import com.divinelink.feature.details.media.ui.DetailsViewState import com.divinelink.feature.details.resources.details__added_to_watchlist -import com.divinelink.feature.details.resources.details__must_be_logged_in_to_rate import com.divinelink.feature.details.resources.details__must_be_logged_in_to_watchlist -import com.divinelink.feature.details.resources.details__rating_deleted_successfully -import com.divinelink.feature.details.resources.details__rating_submitted_successfully import com.divinelink.feature.details.resources.details__removed_from_watchlist import com.divinelink.feature.details.resources.feature_details_jellyseerr_failed_request_delete import com.divinelink.feature.details.resources.feature_details_jellyseerr_failure_media_delete @@ -58,6 +58,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Rule import kotlin.test.Test +import com.divinelink.feature.add.to.account.resources.Res as AccountRes import com.divinelink.feature.details.resources.Res as R class DetailsViewModelTest { @@ -738,7 +739,7 @@ class DetailsViewModelTest { isLoading = false, snackbarMessage = SnackbarMessage.from( UIText.ResourceText( - R.string.details__rating_submitted_successfully, + AccountRes.string.rating_submitted_successfully, movieDetails.title, ), ), @@ -819,7 +820,7 @@ class DetailsViewModelTest { isLoading = false, userDetails = AccountMediaDetailsFactory.NotRated(), snackbarMessage = SnackbarMessage.from( - text = UIText.ResourceText(R.string.details__must_be_logged_in_to_rate), + text = UIText.ResourceText(AccountRes.string.must_be_logged_in_to_rate), actionLabelText = UIText.ResourceText(R.string.login), onSnackbarResult = viewModel::navigateToLogin, ), @@ -859,7 +860,7 @@ class DetailsViewModelTest { userDetails = AccountMediaDetailsFactory.NotRated(), isLoading = false, snackbarMessage = SnackbarMessage.from( - text = UIText.ResourceText(R.string.details__must_be_logged_in_to_rate), + text = UIText.ResourceText(AccountRes.string.must_be_logged_in_to_rate), actionLabelText = UIText.ResourceText(R.string.login), onSnackbarResult = viewModel::navigateToLogin, ), @@ -957,7 +958,7 @@ class DetailsViewModelTest { isLoading = false, snackbarMessage = SnackbarMessage.from( UIText.ResourceText( - R.string.details__rating_submitted_successfully, + AccountRes.string.rating_submitted_successfully, movieDetails.title, ), ), @@ -1035,7 +1036,7 @@ class DetailsViewModelTest { }, snackbarMessage = SnackbarMessage.from( text = UIText.ResourceText( - R.string.details__rating_deleted_successfully, + AccountRes.string.rating_deleted_successfully, movieDetails.title, ), ),