diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt b/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt new file mode 100644 index 0000000..5785ad0 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/ServiceUtils.kt @@ -0,0 +1,11 @@ +package com.owenlejeune.tvtime.api + +import retrofit2.Response + +infix fun Response.storedIn(body: (T) -> Unit) { + if (isSuccessful) { + body()?.let { + body(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt index 79ebf36..4eddec7 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt @@ -4,7 +4,13 @@ import androidx.compose.ui.text.intl.Locale import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.Client import com.owenlejeune.tvtime.api.QueryParam -import com.owenlejeune.tvtime.api.tmdb.api.v3.* +import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.AuthenticationApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.MoviesApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.PeopleApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchApi +import com.owenlejeune.tvtime.api.tmdb.api.v3.TvApi import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Api import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Api import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Api @@ -53,10 +59,6 @@ class TmdbClient: KoinComponent { return clientV4.create(AuthenticationV4Api::class.java) } - fun createGuestSessionService(): GuestSessionApi { - return client.create(GuestSessionApi::class.java) - } - fun createAccountService(): AccountApi { return client.create(AccountApi::class.java) } diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/AccountService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/AccountService.kt index 4a74916..dddeae2 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/AccountService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/AccountService.kt @@ -1,17 +1,52 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 -import com.owenlejeune.tvtime.api.tmdb.TmdbClient -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* +import android.util.Log +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountDetails +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.FavoriteMediaResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.FavoriteMovie +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.FavoriteTvSeries +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MarkAsFavoriteBody +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedEpisode +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMediaResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMovie +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedTv +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchlistBody +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchlistMovie +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchlistResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchlistTvSeries +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.Response -class AccountService { +class AccountService: KoinComponent { - private val accountService by lazy { TmdbClient().createAccountService() } + private val TAG = "AccountService" + + private val accountService: AccountApi by inject() suspend fun getAccountDetails(): Response { return accountService.getAccountDetails() } + suspend fun markAsFavorite(accountId: Int, body: MarkAsFavoriteBody) { + val response = accountService.markAsFavorite(accountId, body) + if (response.isSuccessful) { + Log.d(TAG, "Successfully marked as favourite") + } else { + Log.e(TAG, "Issue marking as favourite") + } + } + + suspend fun addToWatchlist(accountId: Int, body: WatchlistBody) { + val response = accountService.addToWatchlist(accountId, body) + if (response.isSuccessful) { + Log.d(TAG, "Successfully added to watchlist") + } else { + Log.e(TAG, "Issue adding to watchlist") + } + } + + // TODO - replace these with account states API calls suspend fun getFavoriteMovies(accountId: Int, page: Int = 1): Response> { return accountService.getFavoriteMovies(accountId, page) } @@ -20,10 +55,6 @@ class AccountService { return accountService.getFavoriteTvShows(accountId, page) } - suspend fun markAsFavorite(accountId: Int, body: MarkAsFavoriteBody): Response { - return accountService.markAsFavorite(accountId, body) - } - suspend fun getRatedMovies(accountId: Int, page: Int = 1): Response> { return accountService.getRatedMovies(accountId, page) } @@ -44,8 +75,4 @@ class AccountService { return accountService.getTvWatchlist(accountId, page) } - suspend fun addToWatchlist(accountId: Int, body: WatchlistBody): Response { - return accountService.addToWatchlist(accountId, body) - } - } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/ConfigurationService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/ConfigurationService.kt new file mode 100644 index 0000000..0eee33a --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/ConfigurationService.kt @@ -0,0 +1,118 @@ +package com.owenlejeune.tvtime.api.tmdb.api.v3 + +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ConfigurationCountry +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ConfigurationDetails +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ConfigurationJob +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ConfigurationLanguage +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ConfigurationTimezone +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class ConfigurationService: KoinComponent { + + companion object { + private const val TAG = "ConfigurationService" + } + + private val service: ConfigurationApi by inject() + + val detailsConfiguration = mutableStateOf(ConfigurationDetails.Empty) + val countriesConfiguration = mutableStateListOf() + val jobsConfiguration = mutableStateListOf() + val languagesConfiguration = mutableStateListOf() + val primaryTranslationsConfiguration = mutableStateListOf() + val timezonesConfiguration = mutableStateListOf() + + suspend fun getDetailsConfiguration() { + val response = service.getDetailsConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got details configuration: $it") + detailsConfiguration.value = it + } ?: run { + Log.w(TAG, "Problem getting details configuration") + } + } else { + Log.e(TAG, "Issue getting details configuration") + } + } + + suspend fun getCountriesConfiguration() { + val response = service.getCountriesConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got countries configuration: $it") + countriesConfiguration.clear() + countriesConfiguration.addAll(it) + } ?: run { + Log.w(TAG, "Problem getting countries configuration") + } + } else { + Log.e(TAG, "Issue getting counties configuration") + } + } + + suspend fun getJobsConfiguration() { + val response = service.getJobsConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got jobs configuration: $it") + jobsConfiguration.clear() + jobsConfiguration.addAll(it) + } ?: run { + Log.w(TAG, "Problem getting jobs configuration") + } + } else { + Log.e(TAG, "Issue getting jobs configuration") + } + } + + suspend fun getLanguagesConfiguration() { + val response = service.getLanguagesConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got languages configuration: $it") + languagesConfiguration.clear() + languagesConfiguration.addAll(it) + } ?: run { + Log.w(TAG, "Problem getting languages configuration") + } + } else { + Log.e(TAG, "Issue getting languages configuration") + } + } + + suspend fun getPrimaryTranslationsConfiguration() { + val response = service.getPrimaryTranslationsConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got translations configuration: $it") + primaryTranslationsConfiguration.clear() + primaryTranslationsConfiguration.addAll(it) + } ?: run { + Log.w(TAG, "Problem getting translations configuration") + } + } else { + Log.e(TAG, "Issue getting translations configuration") + } + } + + suspend fun getTimezonesConfiguration() { + val response = service.getTimezonesConfiguration() + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got timezone configuration: $it") + timezonesConfiguration.clear() + timezonesConfiguration.addAll(it) + } ?: run { + Log.w(TAG, "Problem getting timezone configuration") + } + } else { + Log.e(TAG, "Issue getting timezone configuration") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/DetailService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/DetailService.kt index 2dfddfe..fe8fffa 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/DetailService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/DetailService.kt @@ -5,26 +5,28 @@ import retrofit2.Response interface DetailService { - suspend fun getById(id: Int): Response + suspend fun getById(id: Int) - suspend fun getImages(id: Int): Response + suspend fun getImages(id: Int) - suspend fun getCastAndCrew(id: Int): Response + suspend fun getCastAndCrew(id: Int) suspend fun getSimilar(id: Int, page: Int): Response - suspend fun getVideos(id: Int): Response + suspend fun getVideos(id: Int) - suspend fun getReviews(id: Int): Response + suspend fun getReviews(id: Int) - suspend fun postRating(id: Int, ratingBody: RatingBody): Response + suspend fun postRating(id: Int, ratingBody: RatingBody) - suspend fun deleteRating(id: Int): Response + suspend fun deleteRating(id: Int) - suspend fun getKeywords(id: Int): Response + suspend fun getKeywords(id: Int) - suspend fun getWatchProviders(id: Int): Response + suspend fun getWatchProviders(id: Int) - suspend fun getExternalIds(id: Int): Response + suspend fun getExternalIds(id: Int) + + suspend fun getAccountStates(id: Int) } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionApi.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionApi.kt deleted file mode 100644 index adc89ba..0000000 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.owenlejeune.tvtime.api.tmdb.api.v3 - -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedEpisode -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMediaResponse -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMovie -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedTv -import retrofit2.Response -import retrofit2.http.GET -import retrofit2.http.Path - -interface GuestSessionApi { - - @GET("guest_session/{session_id}/rated/movies") - suspend fun getRatedMovies(@Path("session_id") sessionId: String): Response> - - @GET("guest_session/{session_id}/rated/tv") - suspend fun getRatedTvShows(@Path("session_id") sessionId: String): Response> - - @GET("guest_session/{session_id}/rated/tv/episodes") - suspend fun getRatedTvEpisodes(@Path("session_id") sessionId: String): Response> - -} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionService.kt deleted file mode 100644 index 3771066..0000000 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/GuestSessionService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.owenlejeune.tvtime.api.tmdb.api.v3 - -import com.owenlejeune.tvtime.api.tmdb.TmdbClient -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedEpisode -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMediaResponse -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedMovie -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatedTv -import retrofit2.Response - -class GuestSessionService { - - private val service by lazy { TmdbClient().createGuestSessionService() } - - suspend fun getRatedMovies(sessionId: String): Response> { - return service.getRatedMovies(sessionId = sessionId) - } - - suspend fun getRatedTvShows(sessionId: String): Response> { - return service.getRatedTvShows(sessionId = sessionId) - } - - suspend fun getRatedTvEpisodes(sessionId: String): Response> { - return service.getRatedTvEpisodes(sessionId = sessionId) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesApi.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesApi.kt index 05cc242..da23580 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesApi.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesApi.kt @@ -61,4 +61,7 @@ interface MoviesApi { @GET("movie/{id}/external_ids") suspend fun getExternalIds(@Path("id") id: Int): Response + @GET("movie/{id}/account_states") + suspend fun getAccountStates(@Path("id") id: Int, @Query("session_id") sessionId: String): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt index ea781d0..7793844 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt @@ -1,25 +1,146 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 -import com.owenlejeune.tvtime.api.tmdb.TmdbClient -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastAndCrew -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem +import android.util.Log +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.owenlejeune.tvtime.api.storedIn +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CrewMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedMovie import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KeywordsResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieReleaseResults import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ReviewResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Searchable +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders import com.owenlejeune.tvtime.utils.SessionManager +import kotlinx.coroutines.flow.Flow import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.Response +import java.util.Collections +import java.util.Locale class MoviesService: KoinComponent, DetailService, HomePageService { - private val movieService by lazy { TmdbClient().createMovieService() } + companion object { + private const val TAG = "MovieService" + } + + private val movieService: MoviesApi by inject() + + val detailMovies = Collections.synchronizedMap(mutableStateMapOf()) + val images = Collections.synchronizedMap(mutableStateMapOf()) + val cast = Collections.synchronizedMap(mutableStateMapOf>()) + val crew = Collections.synchronizedMap(mutableStateMapOf>()) + val videos = Collections.synchronizedMap(mutableStateMapOf>()) + val reviews = Collections.synchronizedMap(mutableStateMapOf>()) + val keywords = Collections.synchronizedMap(mutableStateMapOf>()) + val watchProviders = Collections.synchronizedMap(mutableStateMapOf()) + val externalIds = Collections.synchronizedMap(mutableStateMapOf()) + val releaseDates = Collections.synchronizedMap(mutableStateMapOf>()) + val similar = Collections.synchronizedMap(mutableStateMapOf>>()) + val accountStates = Collections.synchronizedMap(mutableStateMapOf()) + + + override suspend fun getById(id: Int) { + movieService.getMovieById(id) storedIn { detailMovies[id] = it } + } + + override suspend fun getImages(id: Int) { + movieService.getMovieImages(id) storedIn { images[id] = it } + } + + override suspend fun getCastAndCrew(id: Int) { + movieService.getCastAndCrew(id) storedIn { + cast[id] = it.cast + crew[id] = it.crew + } + } + override suspend fun getVideos(id: Int) { + movieService.getVideos(id) storedIn { videos[id] = it.results } + } + + override suspend fun getReviews(id: Int) { + movieService.getReviews(id) storedIn { reviews[id] = it.results } + } + + override suspend fun getKeywords(id: Int) { + movieService.getKeywords(id) storedIn { keywords[id] = it.keywords ?: emptyList() } + } + + override suspend fun getWatchProviders(id: Int) { + movieService.getWatchProviders(id) storedIn { + it.results[Locale.getDefault().country]?.let { wp -> + watchProviders[id] = wp + } + } + } + + override suspend fun getExternalIds(id: Int) { + movieService.getExternalIds(id) storedIn { externalIds[id] = it } + } + + override suspend fun getAccountStates(id: Int) { + val sessionId = SessionManager.currentSession.value?.sessionId ?: throw Exception("Session must not be null") + val response = movieService.getAccountStates(id, sessionId) + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got account states: $it") + accountStates[id] = it + } ?: run { + Log.d(TAG, "Problem getting account states") + } + } else { + Log.d(TAG, "Issue getting account states: $response") + } +// movieService.getAccountStates(id) storedIn { accountStates[id] = it } + } + + suspend fun getReleaseDates(id: Int) { + movieService.getReleaseDates(id) storedIn { releaseDates[id] = it.releaseDates } + } + + override suspend fun postRating(id: Int, ratingBody: RatingBody) { + val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") + val response = movieService.postMovieRatingAsUser(id, session.sessionId, ratingBody) + if (response.isSuccessful) { + Log.d(TAG, "Successfully rated") + SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.Rated) + getAccountStates(id) + } else { + Log.w(TAG, "Issue posting rating") + } + } + + override suspend fun deleteRating(id: Int) { + val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") + val response = movieService.deleteMovieReviewAsUser(id, session.sessionId) + if (response.isSuccessful) { + Log.d(TAG, "Successfully deleted rated") + SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.Rated) + getAccountStates(id) + } else { + Log.w(TAG, "Issue deleting rating") + } + } + + + override suspend fun getSimilar(id: Int, page: Int): Response { + return movieService.getSimilarMovies(id, page) + } override suspend fun getPopular(page: Int): Response { return movieService.getPopularMovies(page) @@ -36,55 +157,42 @@ class MoviesService: KoinComponent, DetailService, HomePageService { override suspend fun getUpcoming(page: Int): Response { return movieService.getUpcomingMovies(page) } +} - suspend fun getReleaseDates(id: Int): Response { - return movieService.getReleaseDates(id) +class SimilarMoviesSource(private val movieId: Int): PagingSource(), KoinComponent { + + private val service: MoviesService by inject() + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition } - override suspend fun getById(id: Int): Response { - return movieService.getMovieById(id) - } - - override suspend fun getImages(id: Int): Response { - return movieService.getMovieImages(id) - } - - override suspend fun getCastAndCrew(id: Int): Response { - return movieService.getCastAndCrew(id) - } - - override suspend fun getSimilar(id: Int, page: Int): Response { - return movieService.getSimilarMovies(id, page) - } - - override suspend fun getVideos(id: Int): Response { - return movieService.getVideos(id) - } - - override suspend fun getReviews(id: Int): Response { - return movieService.getReviews(id) - } - - override suspend fun postRating(id: Int, rating: RatingBody): Response { - val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") - return movieService.postMovieRatingAsUser(id, session.sessionId, rating) - } - - override suspend fun deleteRating(id: Int): Response { - val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") - return movieService.deleteMovieReviewAsUser(id, session.sessionId) - } - - override suspend fun getKeywords(id: Int): Response { - return movieService.getKeywords(id) - } - - override suspend fun getWatchProviders(id: Int): Response { - return movieService.getWatchProviders(id) - } - - override suspend fun getExternalIds(id: Int): Response { - return movieService.getExternalIds(id) + override suspend fun load(params: LoadParams): LoadResult { + return try { + val nextPage = params.key ?: 1 + val response = service.getSimilar(movieId, nextPage) + if (response.isSuccessful) { + val responseBody = response.body() + val result = responseBody?.results ?: emptyList() + LoadResult.Page( + data = result, + prevKey = if (nextPage == 1) { + null + } else { + nextPage - 1 + }, + nextKey = if (result.isEmpty()) { + null + } else { + responseBody?.page?.plus(1) ?: (nextPage + 1) + } + ) + } else { + LoadResult.Invalid() + } + } catch (e: Exception) { + return LoadResult.Error(e) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt index de1be41..9f78eb7 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt @@ -1,36 +1,93 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 +import android.util.Log +import androidx.compose.runtime.mutableStateMapOf import com.owenlejeune.tvtime.api.tmdb.TmdbClient +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCast +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedMovie import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImage import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection +import okhttp3.internal.notify import org.koin.core.component.KoinComponent import retrofit2.Response +import java.util.Collections class PeopleService: KoinComponent { + private val TAG = "PeopleService" + private val service by lazy { TmdbClient().createPeopleService() } - suspend fun getPerson(id: Int): Response { - return service.getPerson(id) + val peopleMap = Collections.synchronizedMap(mutableStateMapOf()) + val castMap = Collections.synchronizedMap(mutableStateMapOf>()) + val crewMap = Collections.synchronizedMap(mutableStateMapOf>()) + val imagesMap = Collections.synchronizedMap(mutableStateMapOf>()) + val externalIdsMap = Collections.synchronizedMap(mutableStateMapOf()) + + suspend fun getPerson(id: Int) { + val response = service.getPerson(id) + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got person $id") + peopleMap[id] = it + } ?: run { + Log.w(TAG, "Problem getting person $id") + } + } else { + Log.e(TAG, "Issue getting person $id") + } } - suspend fun getCredits(id: Int): Response { - return service.getCredits(id) + suspend fun getCredits(id: Int) { + val response = service.getCredits(id) + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got credits $id") + castMap[id] = it.cast + crewMap[id] = it.crew + } ?: run { + Log.w(TAG, "Problem getting credits $id") + } + } else { + Log.e(TAG, "Issue getting credits $id") + } } - suspend fun getImages(id: Int): Response { - return service.getImages(id) + suspend fun getImages(id: Int) { + val response = service.getImages(id) + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got images $id") + imagesMap[id] = it.images + } ?: run { + Log.w(TAG, "Problem getting images $id") + } + } else { + Log.e(TAG, "Issues getting images $id") + } } - suspend fun getPopular(page: Int = 1): Response { + suspend fun getExternalIds(id: Int) { + val response = service.getExternalIds(id) + if (response.isSuccessful) { + response.body()?.let { + Log.d(TAG, "Successfully got external ids $id") + externalIdsMap[id] = it + } ?: run { + Log.w(TAG, "Problem getting external ids $id") + } + } else { + Log.e(TAG, "Issue getting external ids $id") + } + } + + suspend fun getPopular(page: Int): Response { return service.getPopular(page) } - suspend fun getExternalIds(id: Int): Response { - return service.getExternalIds(id) - } - } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/SearchService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/SearchService.kt index d9036f2..508cf7f 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/SearchService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/SearchService.kt @@ -1,46 +1,98 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* +import androidx.compose.runtime.mutableStateOf +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Collection -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ProductionCompany +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMovie +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultPerson +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultTv +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Searchable +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult +import kotlinx.coroutines.flow.Flow import org.koin.core.component.KoinComponent import org.koin.core.component.inject import retrofit2.Response -import java.util.* class SearchService: KoinComponent { - private val service: SearchApi by inject() + private val service: SearchService by inject() - suspend fun searchCompanies(query: String, page: Int = 1): Response> { + val movieResults = mutableStateOf>?>(null) + val tvResults = mutableStateOf>?>(null) + val peopleResults = mutableStateOf>?>(null) + val multiResults = mutableStateOf>?>(null) + + fun searchCompanies(query: String, page: Int = 1): Response> { return service.searchCompanies(query, page) } - suspend fun searchCollections(query: String, page: Int = 1): Response> { + fun searchCollections(query: String, page: Int = 1): Response> { return service.searchCollections(query, page) } - suspend fun searchKeywords(query: String, page: Int = 1): Response> { + fun searchKeywords(query: String, page: Int = 1): Response> { return service.searchKeywords(query, page) } - suspend fun searchMovies(query: String, page: Int = 1): Response> { + fun searchMovies(query: String, page: Int): Response> { return service.searchMovies(query, page) } - suspend fun searchTv(query: String, page: Int = 1): Response> { + fun searchTv(query: String, page: Int): Response> { return service.searchTv(query, page) } - suspend fun searchPeople(query: String, page: Int = 1): Response> { + fun searchPeople(query: String, page: Int): Response> { return service.searchPeople(query, page) } - suspend fun searchMulti(query: String, page: Int = 1): Response> { + fun searchMulti(query: String, page: Int): Response> { return service.searchMulti(query, page) } +} + +typealias SearchResultProvider = suspend (Int) -> Response> + +class SearchPagingSource( + private val provideResults: SearchResultProvider +): PagingSource(), KoinComponent { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val nextPage = params.key ?: 1 + val response = provideResults(nextPage) + if (response.isSuccessful) { + val responseBody = response.body() + val result = responseBody?.results ?: emptyList() + LoadResult.Page( + data = result, + prevKey = if (nextPage == 1) { + null + } else { + nextPage - 1 + }, + nextKey = if (result.isEmpty()) { + null + } else { + responseBody?.page?.plus(1) ?: (nextPage + 1) + } + ) + } else { + LoadResult.Invalid() + } + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt index b7931b3..c3466fb 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt @@ -64,4 +64,7 @@ interface TvApi { @GET("tv/{id}/external_ids") suspend fun getExternalIds(@Path("id") id: Int): Response + @GET("tv/{id}/account_states") + suspend fun getAccountStates(@Path("id") id: Int): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt index f28e50f..2651876 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt @@ -1,26 +1,148 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 +import android.util.Log +import androidx.compose.runtime.mutableStateMapOf +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.owenlejeune.tvtime.api.storedIn import com.owenlejeune.tvtime.api.tmdb.TmdbClient +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastAndCrew +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedTv import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KeywordsResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ReviewResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvContentRatings +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders import com.owenlejeune.tvtime.utils.SessionManager +import kotlinx.coroutines.flow.Flow import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.Response +import java.util.Collections +import java.util.Locale class TvService: KoinComponent, DetailService, HomePageService { - private val service by lazy { TmdbClient().createTvService() } + companion object { + private const val TAG = "TvService" + } + + private val service: TvApi by inject() + + val detailTv = Collections.synchronizedMap(mutableStateMapOf()) + val images = Collections.synchronizedMap(mutableStateMapOf()) + val cast = Collections.synchronizedMap(mutableStateMapOf>()) + val crew = Collections.synchronizedMap(mutableStateMapOf>()) + val videos = Collections.synchronizedMap(mutableStateMapOf>()) + val reviews = Collections.synchronizedMap(mutableStateMapOf>()) + val keywords = Collections.synchronizedMap(mutableStateMapOf>()) + val watchProviders = Collections.synchronizedMap(mutableStateMapOf()) + val externalIds = Collections.synchronizedMap(mutableStateMapOf()) + val contentRatings = Collections.synchronizedMap(mutableStateMapOf>()) + val similar = Collections.synchronizedMap(mutableStateMapOf>>()) + val accountStates = Collections.synchronizedMap(mutableStateMapOf()) + + private val _seasons = Collections.synchronizedMap(mutableStateMapOf>()) + val seasons: MutableMap> + get() = _seasons + + override suspend fun getById(id: Int) { + service.getTvShowById(id) storedIn { detailTv[id] = it } + } + + override suspend fun getImages(id: Int) { + service.getTvImages(id) storedIn { images[id] = it } + } + + override suspend fun getCastAndCrew(id: Int) { + service.getCastAndCrew(id) storedIn { + cast[id] = it.cast + crew[id] = it.crew + } + } + + suspend fun getContentRatings(id: Int) { + service.getContentRatings(id) storedIn { contentRatings[id] = it.results } + } + + override suspend fun getVideos(id: Int) { + service.getVideos(id) storedIn { videos[id] = it.results } + } + + override suspend fun getReviews(id: Int) { + service.getReviews(id) storedIn { reviews[id] = it.results } + } + + override suspend fun getKeywords(id: Int) { + service.getKeywords(id) storedIn { keywords[id] = it.keywords ?: emptyList() } + } + + override suspend fun getWatchProviders(id: Int) { + service.getWatchProviders(id) storedIn { + it.results[Locale.getDefault().country]?.let { wp -> + watchProviders[id] = wp + } + } + } + + override suspend fun getAccountStates(id: Int) { + service.getAccountStates(id) storedIn { accountStates[id] = it } + } + + suspend fun getSeason(seriesId: Int, seasonId: Int) { + service.getSeason(seriesId, seasonId) storedIn { + _seasons[seriesId]?.add(it) ?: run { + _seasons[seriesId] = mutableSetOf(it) + } + } + } + + override suspend fun getExternalIds(id: Int) { + service.getExternalIds(id) storedIn { externalIds[id] = it } + } + + override suspend fun postRating(id: Int, ratingBody: RatingBody) { + val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") + val response = service.postTvRatingAsUser(id, session.sessionId, ratingBody) + if (response.isSuccessful) { + Log.d(TAG, "Successfully posted rating") + SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.Rated) + } else { + Log.w(TAG, "Issue posting rating") + } + } + + override suspend fun deleteRating(id: Int) { + val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") + val response = service.deleteTvReviewAsUser(id, session.sessionId) + if (response.isSuccessful) { + Log.d(TAG, "Successfully deleted rating") + SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.Rated) + } else { + Log.w(TAG, "Issue deleting rating") + } + } + + //todo - turn this into paging + override suspend fun getSimilar(id: Int, page: Int): Response { + return service.getSimilarTvShows(id, page) + } override suspend fun getPopular(page: Int): Response { return service.getPoplarTv(page) @@ -38,58 +160,42 @@ class TvService: KoinComponent, DetailService, HomePageService { return service.getTvOnTheAir(page) } - override suspend fun getById(id: Int): Response { - return service.getTvShowById(id) +} + +class SimilarTvSource(private val tvId: Int): PagingSource(), KoinComponent { + + private val service: TvService by inject() + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition } - override suspend fun getImages(id: Int): Response { - return service.getTvImages(id) - } - - override suspend fun getCastAndCrew(id: Int): Response { - return service.getCastAndCrew(id) - } - - suspend fun getContentRatings(id: Int): Response { - return service.getContentRatings(id) - } - - override suspend fun getSimilar(id: Int, page: Int): Response { - return service.getSimilarTvShows(id, page) - } - - override suspend fun getVideos(id: Int): Response { - return service.getVideos(id) - } - - override suspend fun getReviews(id: Int): Response { - return service.getReviews(id) - } - - override suspend fun postRating(id: Int, ratingBody: RatingBody): Response { - val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") - return service.postTvRatingAsUser(id, session.sessionId, ratingBody) - } - - override suspend fun deleteRating(id: Int): Response { - val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") - return service.deleteTvReviewAsUser(id, session.sessionId) - } - - override suspend fun getKeywords(id: Int): Response { - return service.getKeywords(id) - } - - override suspend fun getWatchProviders(id: Int): Response { - return service.getWatchProviders(id) - } - - suspend fun getSeason(seriesId: Int, seasonId: Int): Response { - return service.getSeason(seriesId, seasonId) - } - - override suspend fun getExternalIds(id: Int): Response { - return service.getExternalIds(id) + override suspend fun load(params: LoadParams): LoadResult { + return try { + val nextPage = params.key ?: 1 + val response = service.getSimilar(tvId, nextPage) + if (response.isSuccessful) { + val responseBody = response.body() + val result = responseBody?.results ?: emptyList() + LoadResult.Page( + data = result, + prevKey = if (nextPage == 1) { + null + } else { + nextPage - 1 + }, + nextKey = if (result.isEmpty()) { + null + } else { + responseBody?.page?.plus(1) ?: (nextPage + 1) + } + ) + } else { + LoadResult.Invalid() + } + } catch (e: Exception) { + return LoadResult.Error(e) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/AccountStatesDeserializer.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/AccountStatesDeserializer.kt new file mode 100644 index 0000000..51da895 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/AccountStatesDeserializer.kt @@ -0,0 +1,22 @@ +package com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer + +import com.google.gson.JsonObject +import com.owenlejeune.tvtime.api.tmdb.api.BaseDeserializer +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates + +class AccountStatesDeserializer: BaseDeserializer() { + + override fun processJson(obj: JsonObject): AccountStates { + val id = obj.get("id").asInt + val isFavorite = obj.get("favorite").asBoolean + val isWatchlist = obj.get("watchlist").asBoolean + return try { + val isRated = obj.get("rated").asBoolean + AccountStates(id, isFavorite, isWatchlist, isRated, -1) + } catch (e: Exception) { + val rating = obj.get("rated").asJsonObject.get("value").asInt + AccountStates(id, isFavorite, isWatchlist, true, rating) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/AccountStates.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/AccountStates.kt new file mode 100644 index 0000000..e361235 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/AccountStates.kt @@ -0,0 +1,14 @@ +package com.owenlejeune.tvtime.api.tmdb.api.v3.model + +import com.google.gson.annotations.SerializedName + +class AccountStates( + @SerializedName("id") + val id: Int, + @SerializedName("favorite") + val isFavorite: Boolean, + @SerializedName("watchlist") + val isWatchListed: Boolean, + val isRated: Boolean, + val rating: Int +) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/GuestSessionResponse.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/GuestSessionResponse.kt deleted file mode 100644 index febd1eb..0000000 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/GuestSessionResponse.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.owenlejeune.tvtime.api.tmdb.api.v3.model - -import com.google.gson.annotations.SerializedName - -class GuestSessionResponse( - @SerializedName("success") val success: Boolean, - @SerializedName("guest_session_id") val guestSessionId: String, - @SerializedName("expires_at") val expiry: String -) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/HomePagePeoplePagingSource.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/HomePagePeoplePagingSource.kt index 29ad28b..4351db8 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/HomePagePeoplePagingSource.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/HomePagePeoplePagingSource.kt @@ -12,8 +12,7 @@ import org.koin.core.component.inject class HomePagePeoplePagingSource: PagingSource(), KoinComponent { - private val service: PeopleApi by inject() - private val context: Context by inject() + private val service: PeopleService by inject() override fun getRefreshKey(state: PagingState): Int? { return state.anchorPosition @@ -32,7 +31,6 @@ class HomePagePeoplePagingSource: PagingSource(), KoinCompo nextKey = if (results.isEmpty() || responseBody == null) null else responseBody.page + 1 ) } else { -// Toast.makeText(context, "No more results found", Toast.LENGTH_SHORT).show() LoadResult.Invalid() } } catch (e: Exception) { diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Api.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Api.kt index 82c88fb..ee6c6d1 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Api.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Api.kt @@ -7,11 +7,7 @@ import retrofit2.http.* interface ListV4Api { @GET("list/{id}") - suspend fun getList( - @Path("id") listId: Int, - @Query("api_key") apiKey: String, - @Query("page") page: Int = 1 - ): Response + suspend fun getList(@Path("id") listId: Int): Response @POST("list") suspend fun createList(@Body body: CreateListBody): Response diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Service.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Service.kt index 536df1f..42abe89 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Service.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v4/ListV4Service.kt @@ -1,52 +1,74 @@ package com.owenlejeune.tvtime.api.tmdb.api.v4 +import android.util.Log +import androidx.compose.runtime.mutableStateMapOf import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.api.v4.model.* import com.owenlejeune.tvtime.preferences.AppPreferences +import com.owenlejeune.tvtime.utils.SessionManager import org.koin.core.component.KoinComponent import org.koin.core.component.inject import retrofit2.Response class ListV4Service: KoinComponent { - private val service by lazy { TmdbClient().createV4ListService() } - - private val preferences: AppPreferences by inject() - - suspend fun getList(listId: Int, page: Int = 1): Response { - return service.getList(listId, BuildConfig.TMDB_Api_v4Key, page) + companion object { + private const val TAG = "ListV4Service" } - suspend fun createList(body: CreateListBody): Response { - return service.createList(body) + private val service: ListV4Api by inject() + + val listMap = mutableStateMapOf() + + suspend fun getList(listId: Int) { + val response = service.getList(listId) + if (response.isSuccessful) { + response.body()?.let { + listMap[listId] = it + } + } } - suspend fun updateList(listId: Int, body: ListUpdateBody): Response { - return service.updateList(listId, body) + suspend fun createList(body: CreateListBody) {//}: Response { + service.createList(body) } - suspend fun clearList(listId: Int): Response { - return service.clearList(listId) + suspend fun updateList(listId: Int, body: ListUpdateBody) { + val response = service.updateList(listId, body) + if (response.isSuccessful) { + Log.d(TAG, "Successfully updated list $listId") + getList(listId) + } else { + Log.w(TAG, "Issue updating list $listId") + } } - suspend fun deleteList(listId: Int): Response { - return service.deleteList(listId) + suspend fun deleteListItems(listId: Int, body: DeleteListItemsBody) { + val response = service.deleteListItems(listId, body) + if (response.isSuccessful) { + SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.List) + getList(listId) + } } - suspend fun addItemsToList(listId: Int, body: AddToListBody): Response { - return service.addItemsToList(listId, body) + suspend fun clearList(listId: Int) {//}: Response { + service.clearList(listId) } - suspend fun updateListItems(listId: Int, body: UpdateListItemBody): Response { - return service.updateListItems(listId, body) + suspend fun deleteList(listId: Int) {//}: Response { + service.deleteList(listId) } - suspend fun deleteListItems(listId: Int, body: DeleteListItemsBody): Response { - return service.deleteListItems(listId, body) + suspend fun addItemsToList(listId: Int, body: AddToListBody) {//}: Response { + service.addItemsToList(listId, body) } - suspend fun getListItemStatus(listId: Int, mediaId: Int, mediaType: String): Response { - return service.getListItemStatus(listId, mediaId, mediaType) + suspend fun updateListItems(listId: Int, body: UpdateListItemBody) {//}: Response { + service.updateListItems(listId, body) + } + + suspend fun getListItemStatus(listId: Int, mediaId: Int, mediaType: String) {//}: Response { + service.getListItemStatus(listId, mediaId, mediaType) } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/paging/PopularMovieSource.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/paging/PopularMovieSource.kt deleted file mode 100644 index b166b72..0000000 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/paging/PopularMovieSource.kt +++ /dev/null @@ -1,38 +0,0 @@ -//package com.owenlejeune.tvtime.api.tmdb.paging -// -//import androidx.paging.PagingSource -//import androidx.paging.PagingState -//import com.owenlejeune.tvtime.api.tmdb.TmdbClient -//import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PopularMovie -//import retrofit2.HttpException -//import java.io.IOException -// -//class PopularMovieSource: PagingSource() { -// -// companion object { -// const val MIN_PAGE = 1 -// const val MAX_PAGE = 1000 -// } -// -// private val movieService by lazy { TmdbClient().createMovieService() } -// -// override fun getRefreshKey(state: PagingState): Int? { -// return state.anchorPosition -// } -// -// override suspend fun load(params: LoadParams): LoadResult { -// return try { -// val nextPage = params.key ?: 1 -// val movieList = movieService.getPopularMovies(page = nextPage) -// LoadResult.Page( -// data = movieList.movies, -// prevKey = if (nextPage == MIN_PAGE) null else nextPage - 1, -// nextKey = if (movieList.count == 0 || nextPage > MAX_PAGE) null else movieList.page + 1 -// ) -// } catch (exception: IOException) { -// return LoadResult.Error(exception) -// } catch (exception: HttpException) { -// return LoadResult.Error(exception) -// } -// } -//} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt index 44438dd..62f0748 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt @@ -6,11 +6,21 @@ import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.* import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService +import com.owenlejeune.tvtime.api.tmdb.api.v3.AuthenticationService +import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationService +import com.owenlejeune.tvtime.api.tmdb.api.v3.MoviesService +import com.owenlejeune.tvtime.api.tmdb.api.v3.PeopleService +import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchService +import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService +import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.AccountStatesDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.KnownForDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SortableSearchResultDeserializer +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KnownFor import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service +import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Service +import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Service import com.owenlejeune.tvtime.api.tmdb.api.v4.deserializer.ListItemDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem import com.owenlejeune.tvtime.preferences.AppPreferences @@ -30,7 +40,6 @@ val networkModule = module { single { get().createV4AccountService() } single { get().createV4ListService() } single { get().createAccountService() } - single { get().createGuestSessionService() } single { get().createAuthenticationService() } single { get().createMovieService() } single { get().createPeopleService() } @@ -38,14 +47,23 @@ val networkModule = module { single { get().createTvService() } single { get().createConfigurationService() } + single { ConfigurationService() } + single { MoviesService() } + single { TvService() } single { AccountService() } + single { AuthenticationService() } + single { PeopleService() } + single { SearchService() } single { AccountV4Service() } + single { AuthenticationV4Service() } + single { ListV4Service() } single, JsonDeserializer<*>>> { mapOf( ListItem::class.java to ListItemDeserializer(), KnownFor::class.java to KnownForDeserializer(), - SortableSearchResult::class.java to SortableSearchResultDeserializer() + SortableSearchResult::class.java to SortableSearchResultDeserializer(), + AccountStates::class.java to AccountStatesDeserializer() ) } diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt new file mode 100644 index 0000000..b95d210 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt @@ -0,0 +1,9 @@ +package com.owenlejeune.tvtime.extensions + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +fun Any.coroutineTask(runnable: suspend () -> Unit) { + CoroutineScope(Dispatchers.IO).launch { runnable() } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt new file mode 100644 index 0000000..f1e5190 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Actions.kt @@ -0,0 +1,258 @@ +package com.owenlejeune.tvtime.ui.components + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.owenlejeune.tvtime.R +import com.owenlejeune.tvtime.ui.theme.FavoriteSelected +import com.owenlejeune.tvtime.ui.theme.RatingSelected +import com.owenlejeune.tvtime.ui.theme.WatchlistSelected +import com.owenlejeune.tvtime.ui.theme.actionButtonColor +import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel +import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel +import com.owenlejeune.tvtime.utils.SessionManager +import com.owenlejeune.tvtime.utils.types.MediaViewType +import kotlinx.coroutines.launch +import java.text.DecimalFormat + +enum class Actions { + RATE, + WATCHLIST, + LIST, + FAVORITE +} + +@Composable +fun ActionsView( + itemId: Int, + type: MediaViewType, + actions: List = listOf(Actions.RATE, Actions.WATCHLIST, Actions.LIST, Actions.FAVORITE), + modifier: Modifier = Modifier +) { + val accountViewModel = viewModel() + val mainViewModel = viewModel() + + LaunchedEffect(Unit) { + mainViewModel.getAccountStates(itemId, type) + } + + Row( + modifier = modifier + .wrapContentSize() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (actions.contains(Actions.RATE)) { + RateButton( + itemId = itemId, + type = type, + mainViewModel = mainViewModel + ) + } + if (actions.contains(Actions.WATCHLIST)) { + WatchlistButton( + itemId = itemId, + type = type, + accountViewModel = accountViewModel, + mainViewModel = mainViewModel + ) + } + if (actions.contains(Actions.FAVORITE)) { + FavoriteButton( + itemId = itemId, + type = type, + accountViewModel = accountViewModel, + mainViewModel = mainViewModel + ) + } + if (actions.contains(Actions.LIST)) { + ListButton( + itemId = itemId, + type = type + ) + } + } +} + +@Composable +fun ActionButton( + imageVector: ImageVector, + contentDescription: String, + isSelected: Boolean, + filledIconColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val bgColor = MaterialTheme.colorScheme.background + val tintColor = remember { Animatable(bgColor) } + LaunchedEffect(isSelected) { + val target = if (isSelected) filledIconColor else bgColor + tintColor.animateTo(targetValue = target, animationSpec = tween(300)) + } + + Box( + modifier = modifier + .clip(CircleShape) + .height(40.dp) + .requiredWidthIn(min = 40.dp) + .background(color = MaterialTheme.colorScheme.actionButtonColor) + .clickable(onClick = onClick) + ) { + Icon( + modifier = Modifier + .clip(CircleShape) + .align(Alignment.Center), + imageVector = imageVector, + contentDescription = contentDescription, + tint = tintColor.value + ) + } +} + +@Composable +private fun RateButton( + itemId: Int, + type: MediaViewType, + mainViewModel: MainViewModel, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + val accountStates = remember { mainViewModel.produceAccountStatesFor(type) } + val itemIsRated = accountStates[itemId]?.isRated ?: false + + val showRatingDialog = remember { mutableStateOf(false) } + + ActionButton( + imageVector = Icons.Filled.Star, + contentDescription = "", + isSelected = itemIsRated, + filledIconColor = RatingSelected, + onClick = { showRatingDialog.value = true }, + modifier = modifier + ) + + val userRating = accountStates[itemId]?.rating?.times(2)?.toFloat() ?: 0f + RatingDialog( + showDialog = showRatingDialog, + rating = userRating, + onValueConfirmed = { rating -> + if (rating > 0f) { + scope.launch { mainViewModel.postRating(itemId, rating, type) } + } else { + scope.launch { mainViewModel.deleteRating(itemId, type) } + } + } + ) +} + +@Composable +fun WatchlistButton( + itemId: Int, + type: MediaViewType, + accountViewModel: AccountViewModel, + mainViewModel: MainViewModel, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + val accountStates = remember { mainViewModel.produceAccountStatesFor(type) } + val itemIsWatchlisted = accountStates[itemId]?.isWatchListed ?: false + + ActionButton( + modifier = modifier, + imageVector = Icons.Filled.Bookmark, + contentDescription = "", + isSelected = itemIsWatchlisted, + filledIconColor = WatchlistSelected, + onClick = { + scope.launch { + accountViewModel.addToWatchlist(type, itemId, !itemIsWatchlisted) + mainViewModel.getAccountStates(itemId, type) + } + } + ) +} + +@Composable +fun ListButton( + itemId: Int, + type: MediaViewType, + modifier: Modifier = Modifier +) { + CircleBackgroundColorImage( + modifier = modifier.clickable( + onClick = {} + ), + size = 40.dp, + backgroundColor = MaterialTheme.colorScheme.actionButtonColor, + image = Icons.Filled.List, + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.background), + contentDescription = "" + ) +} + +@Composable +fun FavoriteButton( + itemId: Int, + type: MediaViewType, + accountViewModel: AccountViewModel, + mainViewModel: MainViewModel, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + val accountStates = remember { mainViewModel.produceAccountStatesFor(type) } + val itemIsFavorited = accountStates[itemId]?.isFavorite ?: false + + ActionButton( + modifier = modifier, + imageVector = Icons.Filled.Favorite, + contentDescription = "", + isSelected = itemIsFavorited, + filledIconColor = FavoriteSelected, + onClick = { + scope.launch { + accountViewModel.addToFavourites(type, itemId, !itemIsFavorited) + mainViewModel.getAccountStates(itemId, type) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Dialogs.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Dialogs.kt new file mode 100644 index 0000000..41fef8f --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Dialogs.kt @@ -0,0 +1,77 @@ +package com.owenlejeune.tvtime.ui.components + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.owenlejeune.tvtime.R +import java.text.DecimalFormat + +@Composable +fun RatingDialog( + showDialog: MutableState, + rating: Float, + onValueConfirmed: (Float) -> Unit +) { + val formatPosition: (Float) -> String = { position -> + DecimalFormat("#.#").format(position.toInt()*5/10f) + } + + if (showDialog.value) { + var sliderPosition by remember { mutableStateOf(rating) } + val formatted = formatPosition(sliderPosition).toFloat() + AlertDialog( + modifier = Modifier.wrapContentHeight(), + onDismissRequest = { showDialog.value = false }, + title = { Text(text = stringResource(R.string.rating_dialog_title)) }, + confirmButton = { + Button( + modifier = Modifier.height(40.dp), + onClick = { + onValueConfirmed.invoke(formatted) + showDialog.value = false + } + ) { + Text( + text = if (formatted > 0f) { + stringResource(id = R.string.rating_dialog_confirm) + } else { + stringResource(id = R.string.rating_dialog_delete) + } + ) + } + }, + dismissButton = { + Button( + modifier = Modifier.height(40.dp), + onClick = { + showDialog.value = false + } + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + text = { + SliderWithLabel( + value = sliderPosition, + valueRange = 0f..20f, + onValueChanged = { + sliderPosition = it + }, + sliderLabel = "${sliderPosition.toInt() * 5}%" + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Sliders.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Sliders.kt index 7970ee7..92bd8e3 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Sliders.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Sliders.kt @@ -19,10 +19,11 @@ fun SliderWithLabel( valueRange: ClosedFloatingPointRange, onValueChanged: (Float) -> Unit, sliderLabel: String, + modifier: Modifier = Modifier, steps: Int = 0, - labelMinWidth: Dp = 36.dp + labelMinWidth: Dp = 46.dp ) { - Column { + Column(modifier = modifier) { BoxWithConstraints( modifier = Modifier .fillMaxWidth() @@ -60,7 +61,7 @@ fun SliderWithLabel( @Composable fun SliderLabel(label: String, minWidth: Dp, modifier: Modifier = Modifier) { Text( - label, + text = label, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onPrimary, modifier = modifier diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AccountTabNavItem.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AccountTabNavItem.kt index 04d165f..e5ca603 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AccountTabNavItem.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AccountTabNavItem.kt @@ -36,9 +36,6 @@ sealed class AccountTabNavItem( val noContentText = resourceUtils.getString(noContentStringRes) companion object { - val GuestItems - get() = listOf(RatedMovies, RatedTvShows, RatedTvEpisodes) - val AuthorizedItems get() = listOf( RatedMovies, RatedTvShows, RatedTvEpisodes, FavoriteMovies, FavoriteTvShows, diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/ListDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/ListDetailScreen.kt index b9c48cc..8f80f3a 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/ListDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/ListDetailScreen.kt @@ -1,4 +1,5 @@ package com.owenlejeune.tvtime.ui.screens +import android.accounts.Account import android.content.Context import android.content.Intent import android.util.Log @@ -35,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.google.accompanist.systemuicontroller.rememberSystemUiController @@ -47,11 +49,15 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.model.* import com.owenlejeune.tvtime.extensions.WindowSizeClass import com.owenlejeune.tvtime.extensions.unlessEmpty import com.owenlejeune.tvtime.preferences.AppPreferences +import com.owenlejeune.tvtime.ui.components.Actions +import com.owenlejeune.tvtime.ui.components.ActionsView +import com.owenlejeune.tvtime.ui.components.FavoriteButton import com.owenlejeune.tvtime.ui.components.RatingView import com.owenlejeune.tvtime.ui.components.Spinner import com.owenlejeune.tvtime.ui.components.SwitchPreference import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.theme.* +import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.types.MediaViewType @@ -68,22 +74,22 @@ import kotlin.math.roundToInt @Composable fun ListDetailScreen( appNavController: NavController, - itemId: Int?, + itemId: Int, windowSize: WindowSizeClass, - preferences: AppPreferences = KoinJavaComponent.get(AppPreferences::class.java) + service: ListV4Service = KoinJavaComponent.get(ListV4Service::class.java) ) { + val accountViewModel = viewModel() + LaunchedEffect(Unit) { + accountViewModel.getList(itemId) + } + val systemUiController = rememberSystemUiController() systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background) - val service = ListV4Service() + val listMap = remember { accountViewModel.listMap } + val parentList = listMap[itemId] - val parentList = remember { mutableStateOf(null) } - itemId?.let { - if (parentList.value == null) { - fetchList(itemId, service, parentList) - } - } val decayAnimationSpec = rememberSplineBasedDecay() val topAppBarScrollState = rememberTopAppBarScrollState() @@ -101,7 +107,7 @@ fun ListDetailScreen( scrolledContainerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.primary ), - title = { Text(text = parentList.value?.name ?: "") }, + title = { Text(text = parentList?.name ?: "") }, navigationIcon = { IconButton( onClick = { appNavController.popBackStack() } @@ -117,7 +123,7 @@ fun ListDetailScreen( } ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { - parentList.value?.let { mediaList -> + parentList?.let { mediaList -> Column( modifier = Modifier .padding(all = 12.dp) @@ -151,7 +157,7 @@ private fun ListHeader( list: MediaList, selectedSortOrder: MutableState, service: ListV4Service, - parentList: MutableState + parentList: MediaList? ) { val context = LocalContext.current @@ -254,10 +260,7 @@ private fun ListHeader( if (showEditListDialog.value) { EditListDialog( showEditListDialog = showEditListDialog, - list = list, - service = service, - parentList = parentList, - selectedSortOrder = selectedSortOrder + list = list ) } } @@ -312,11 +315,10 @@ private fun SortOrderDialog( @Composable private fun EditListDialog( showEditListDialog: MutableState, - list: MediaList, - service: ListV4Service, - parentList: MutableState, - selectedSortOrder: MutableState + list: MediaList ) { + val accountViewModel = viewModel() + val coroutineScope = rememberCoroutineScope() var listTitle by remember { mutableStateOf(list.name) } @@ -338,11 +340,7 @@ private fun EditListDialog( onClick = { val listUpdateBody = ListUpdateBody(listTitle, listDescription, isPublicList, editSelectedSortOrder) coroutineScope.launch { - val response = service.updateList(list.id, listUpdateBody) - if (response.isSuccessful) { - fetchList(list.id, service, parentList) - selectedSortOrder.value = editSelectedSortOrder - } + accountViewModel.updateList(list.id, listUpdateBody) showEditListDialog.value = false } } @@ -426,23 +424,24 @@ private fun RowScope.OverviewStatCard( private fun ListItemView( appNavController: NavController, listItem: ListItem, - list: MutableState + list: MediaList? ) { - val context = LocalContext.current + val accountViewModel = viewModel() + val scope = rememberCoroutineScope() + RevealSwipe ( directions = setOf(RevealDirection.EndToStart), hiddenContentEnd = { IconButton( modifier = Modifier.padding(horizontal = 15.dp), onClick = { - removeItemFromList( - context = context, - itemId = listItem.id, - itemType = listItem.mediaType, - itemName = listItem.title, - service = ListV4Service(), - list = list - ) + scope.launch { + accountViewModel.deleteListItem( + list?.id ?: -1, + listItem.id, + listItem.mediaType + ) + } } ) { Icon( @@ -533,7 +532,11 @@ private fun ListItemView( fontSize = 18.sp, fontWeight = FontWeight.Bold ) - ActionButtonRow(listItem) + ActionsView( + itemId = listItem.id, + type = listItem.mediaType, + actions = listOf(Actions.RATE, Actions.WATCHLIST, Actions.FAVORITE) + ) Spacer(modifier = Modifier.weight(1f)) } @@ -553,109 +556,6 @@ private fun ListItemView( } } -@Composable -private fun ActionButtonRow(listItem: ListItem) { - val session = SessionManager.currentSession.value - - val (isFavourited, isWatchlisted, isRated) = if (listItem.mediaType == MediaViewType.MOVIE) { - Triple( - session?.hasFavoritedMovie(listItem.id) == true, - session?.hasWatchlistedMovie(listItem.id) == true, - session?.hasRatedMovie(listItem.id) == true - ) - } else { - Triple( - session?.hasFavoritedTvShow(listItem.id) == true, - session?.hasWatchlistedTvShow(listItem.id) == true, - session?.hasRatedTvShow(listItem.id) == true - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ActionButton( - itemId = listItem.id, - type = listItem.mediaType, - imageVector = Icons.Filled.Favorite, - contentDescription = stringResource(id = R.string.favourite_label), - isSelected = isFavourited, - filledIconColor = FavoriteSelected, - onClick = ::listAddToFavorite - ) - - ActionButton( - itemId = listItem.id, - type = listItem.mediaType, - imageVector = Icons.Filled.Bookmark, - contentDescription = "", - isSelected = isWatchlisted, - filledIconColor = WatchlistSelected, - onClick = ::listAddToWatchlist - ) - - val context = LocalContext.current - ActionButton( - itemId = listItem.id, - type = listItem.mediaType, - imageVector = Icons.Filled.Star, - contentDescription = "", - isSelected = isRated, - filledIconColor = RatingSelected, - onClick = { c, i, t, s, f -> - // todo - add rating - Toast.makeText(context, "Rating", Toast.LENGTH_SHORT).show() - } - ) - } -} - -private fun listAddToWatchlist( - context: Context, - itemId: Int, - type: MediaViewType, - itemIsWatchlisted: MutableState, - onWatchlistChanged: (Boolean) -> Unit -) { - val currentSession = SessionManager.currentSession.value - val accountId = currentSession!!.accountDetails.value!!.id - CoroutineScope(Dispatchers.IO).launch { - val response = AccountService().addToWatchlist(accountId, WatchlistBody(type, itemId, !itemIsWatchlisted.value)) - if (response.isSuccessful) { - currentSession.refresh(changed = SessionManager.Session.Changed.Watchlist) - withContext(Dispatchers.Main) { - itemIsWatchlisted.value = !itemIsWatchlisted.value - onWatchlistChanged(itemIsWatchlisted.value) - } - } else { - withContext(Dispatchers.Main) { - Toast.makeText(context, "An error occurred", Toast.LENGTH_SHORT).show() - } - } - } -} - -private fun listAddToFavorite( - context: Context, - itemId: Int, - type: MediaViewType, - itemIsFavorited: MutableState, - onFavoriteChanged: (Boolean) -> Unit -) { - val currentSession = SessionManager.currentSession.value - val accountId = currentSession!!.accountDetails.value!!.id - CoroutineScope(Dispatchers.IO).launch { - val response = AccountService().markAsFavorite(accountId, MarkAsFavoriteBody(type, itemId, !itemIsFavorited.value)) - if (response.isSuccessful) { - currentSession.refresh(changed = SessionManager.Session.Changed.Favorites) - withContext(Dispatchers.Main) { - itemIsFavorited.value = !itemIsFavorited.value - onFavoriteChanged(itemIsFavorited.value) - } - } - } -} - private fun shareListUrl(context: Context, listId: Int) { val shareUrl = "https://www.themoviedb.org/list/$listId" val sendIntent = Intent().apply { @@ -665,46 +565,4 @@ private fun shareListUrl(context: Context, listId: Int) { } val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) -} - -private fun fetchList( - itemId: Int, - service: ListV4Service, - listItem: MutableState -) { - CoroutineScope(Dispatchers.IO).launch { - val response = service.getList(itemId) - if (response.isSuccessful) { - withContext(Dispatchers.Main) { - listItem.value = response.body() - } - } - } -} - -private fun removeItemFromList( - context: Context, - itemName: String, - itemId: Int, - itemType: MediaViewType, - service: ListV4Service, - list: MutableState -) { - CoroutineScope(Dispatchers.IO).launch { - val listId = list.value?.id ?: 0 - val removeItem = DeleteListItemsItem(itemId, itemType) - val result = service.deleteListItems(listId, DeleteListItemsBody(listOf(removeItem))) - if (result.isSuccessful) { - SessionManager.currentSession.value?.refresh(SessionManager.Session.Changed.List) - service.getList(listId).body()?.let { - withContext(Dispatchers.Main) { - list.value = it - } - } - Toast.makeText(context, "Successfully removed $itemName", Toast.LENGTH_SHORT).show() - } else { - Log.w("RemoveListItemError", result.toString()) - Toast.makeText(context, "An error occurred!", Toast.LENGTH_SHORT).show() - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt index 27fb280..6c916b8 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt @@ -1,6 +1,5 @@ package com.owenlejeune.tvtime.ui.screens -import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast @@ -10,7 +9,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -18,30 +16,25 @@ import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.* -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import androidx.paging.compose.collectAsLazyPagingItems import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.pager.ExperimentalPagerApi @@ -50,63 +43,43 @@ import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.owenlejeune.tvtime.R -import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService -import com.owenlejeune.tvtime.api.tmdb.api.v3.DetailService -import com.owenlejeune.tvtime.api.tmdb.api.v3.MoviesService -import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* import com.owenlejeune.tvtime.extensions.WindowSizeClass +import com.owenlejeune.tvtime.extensions.lazyPagingItems import com.owenlejeune.tvtime.extensions.listItems -import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.components.* import com.owenlejeune.tvtime.ui.navigation.AppNavItem -import com.owenlejeune.tvtime.utils.types.TabNavItem -import com.owenlejeune.tvtime.ui.components.DetailHeader -import com.owenlejeune.tvtime.utils.types.MediaViewType -import com.owenlejeune.tvtime.ui.components.Tabs -import com.owenlejeune.tvtime.ui.theme.FavoriteSelected -import com.owenlejeune.tvtime.ui.theme.RatingSelected -import com.owenlejeune.tvtime.ui.theme.WatchlistSelected -import com.owenlejeune.tvtime.ui.theme.actionButtonColor +import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.TmdbUtils +import com.owenlejeune.tvtime.utils.types.MediaViewType +import com.owenlejeune.tvtime.utils.types.TabNavItem import kotlinx.coroutines.* -import org.json.JSONObject -import org.koin.java.KoinJavaComponent.get -import java.text.DecimalFormat @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @Composable fun MediaDetailScreen( appNavController: NavController, - itemId: Int?, + itemId: Int, type: MediaViewType, - windowSize: WindowSizeClass, - preferences: AppPreferences = get(AppPreferences::class.java) + windowSize: WindowSizeClass ) { + val mainViewModel = viewModel() + val systemUiController = rememberSystemUiController() systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background) - val service = when (type) { - MediaViewType.MOVIE -> MoviesService() - MediaViewType.TV -> TvService() - else -> throw IllegalArgumentException("Media type given: ${type}, \n expected one of MediaViewType.MOVIE, MediaViewType.TV") // shouldn't happen + LaunchedEffect(Unit) { + mainViewModel.getById(itemId, type) + mainViewModel.getImages(itemId, type) } - val mediaItem = remember { mutableStateOf(null) } - itemId?.let { - if (mediaItem.value == null) { - fetchMediaItem(itemId, service, mediaItem) - } - } + val mediaItems: Map = remember { mainViewModel.produceDetailsFor(type) } + val mediaItem = mediaItems[itemId] - val images = remember { mutableStateOf(null) } - itemId?.let { - if (preferences.showBackdropGallery && images.value == null) { - fetchImages(itemId, service, images) - } - } + val imagesMap = remember { mainViewModel.produceImagesFor(type) } + val images = imagesMap[itemId] val decayAnimationSpec = rememberSplineBasedDecay() val topAppBarScrollState = rememberTopAppBarScrollState() @@ -130,7 +103,7 @@ fun MediaDetailScreen( scrolledContainerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.primary ), - title = { Text(text = mediaItem.value?.title ?: "") }, + title = { Text(text = mediaItem?.title ?: "") }, navigationIcon = { IconButton( onClick = { appNavController.popBackStack() } @@ -151,17 +124,17 @@ fun MediaDetailScreen( itemId = itemId, mediaItem = mediaItem, images = images, - service = service, type = type, windowSize = windowSize, showImageGallery = showGalleryOverlay, - pagerState = pagerState + pagerState = pagerState, + mainViewModel = mainViewModel ) } } if (showGalleryOverlay.value) { - images.value?.let { + images?.let { ImageGalleryOverlay( imageCollection = it, selectedImage = pagerState.currentPage, @@ -176,16 +149,18 @@ fun MediaDetailScreen( @Composable private fun MediaViewContent( appNavController: NavController, - itemId: Int?, - mediaItem: MutableState, - images: MutableState, - service: DetailService, + mainViewModel: MainViewModel, + itemId: Int, + mediaItem: DetailedItem?, + images: ImageCollection?, type: MediaViewType, windowSize: WindowSizeClass, showImageGallery: MutableState, pagerState: PagerState ) { - val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + mainViewModel.getExternalIds(itemId, type) + } Row( modifier = Modifier @@ -200,11 +175,11 @@ private fun MediaViewContent( verticalArrangement = Arrangement.spacedBy(16.dp) ) { DetailHeader( - posterUrl = TmdbUtils.getFullPosterPath(mediaItem.value?.posterPath), - posterContentDescription = mediaItem.value?.title, - backdropUrl = TmdbUtils.getFullBackdropPath(mediaItem.value?.backdropPath), - rating = mediaItem.value?.voteAverage?.let { it / 10 }, - imageCollection = images.value, + posterUrl = TmdbUtils.getFullPosterPath(mediaItem?.posterPath), + posterContentDescription = mediaItem?.title, + backdropUrl = TmdbUtils.getFullBackdropPath(mediaItem?.backdropPath), + rating = mediaItem?.voteAverage?.let { it / 10 }, + imageCollection = images, showGalleryOverlay = showImageGallery, pagerState = pagerState ) @@ -212,79 +187,44 @@ private fun MediaViewContent( Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - if (type == MediaViewType.MOVIE) { - MiscMovieDetails(mediaItem = mediaItem, service as MoviesService) - } else { - MiscTvDetails(mediaItem = mediaItem, service as TvService) - } + DetailsFor( + type = type, + mediaItem = mediaItem, + mainViewModel = mainViewModel, + itemId = itemId + ) - val externalIds = remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - scope.launch { - val response = service.getExternalIds(itemId!!) - if (response.isSuccessful) { - externalIds.value = response.body()!! - } - } - } - externalIds.value?.let { + val externalIdsMap = remember { mainViewModel.produceExternalIdsFor(type) } + externalIdsMap[itemId]?.let { ExternalIdsArea( externalIds = it, modifier = Modifier.padding(start = 20.dp) ) } - ActionsView(itemId = itemId, type = type, service = service) + val currentSession = remember { SessionManager.currentSession } + currentSession.value?.let { + ActionsView(itemId = itemId, type = type) + } if (type == MediaViewType.MOVIE) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(horizontal = 16.dp) - ) { - MainContent( - itemId = itemId, - mediaItem = mediaItem, - type = type, - service = service, - appNavController = appNavController, - windowSize = windowSize - ) - } + MainContentMovie( + itemId = itemId, + mediaItem = mediaItem, + type = type, + appNavController = appNavController, + windowSize = windowSize, + mainViewModel = mainViewModel + ) } else { - val tabState = rememberPagerState() - val tabs = listOf(DetailsTab, SeasonsTab) - TvSeriesTabs(pagerState = tabState, tabs = tabs) - HorizontalPager( - count = tabs.size, - state = tabState, - userScrollEnabled = false - ) { page -> - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(horizontal = 16.dp) - ) { - when (tabs[page]) { - is DetailsTab -> { - MainContent( - itemId = itemId, - mediaItem = mediaItem, - type = type, - service = service, - appNavController = appNavController, - windowSize = windowSize - ) - } - - is SeasonsTab -> { - SeasonsTab( - itemId = itemId, - mediaItem = mediaItem, - service = service as TvService - ) - } - } - } - } + MainContentTv( + itemId = itemId, + mediaItem = mediaItem, + type = type, + appNavController = appNavController, + windowSize = windowSize, + mainViewModel = mainViewModel + ) } } @@ -298,7 +238,7 @@ private fun MediaViewContent( .weight(1f) .verticalScroll(state = rememberScrollState()) ) { - ReviewsCard(itemId = itemId, service = service) + ReviewsCard(itemId = itemId, type = type, mainViewModel = mainViewModel) Spacer(modifier = Modifier.height(16.dp)) } @@ -321,32 +261,21 @@ private fun TvSeriesTabs( @Composable private fun SeasonsTab( - itemId: Int?, - mediaItem: MutableState, - service: TvService + itemId: Int, + mediaItem: DetailedItem?, + mainViewModel: MainViewModel ) { - val scope = rememberCoroutineScope() - - mediaItem.value?.let { tv -> - val series = tv as DetailedTv - - val seasons = remember { mutableStateMapOf() } - LaunchedEffect(Unit) { - itemId?.let { - for (i in 0..series.numberOfSeasons) { - scope.launch { - val season = service.getSeason(it, i) - if (season.isSuccessful) { - seasons[i] = season.body()!! - } - } - } - } + LaunchedEffect(Unit) { + for (i in 0..(mediaItem as DetailedTv).numberOfSeasons) { + mainViewModel.getSeason(itemId, i) } + } - seasons.toSortedMap().values.forEach { season -> - SeasonSection(season = season) - } + val seasonsMap = remember { mainViewModel.tvSeasons } + val seasons = seasonsMap[itemId] + + seasons?.forEach { season -> + SeasonSection(season = season) } } @@ -451,37 +380,44 @@ private fun EpisodeItem(episode: Episode) { @Composable private fun MainContent( - itemId: Int?, - mediaItem: MutableState, + itemId: Int, + mediaItem: DetailedItem?, type: MediaViewType, - service: DetailService, appNavController: NavController, - windowSize: WindowSizeClass + windowSize: WindowSizeClass, + mainViewModel: MainViewModel ) { - OverviewCard(itemId = itemId, mediaItem = mediaItem, service = service) + OverviewCard(itemId = itemId, mediaItem = mediaItem, type = type, mainViewModel = mainViewModel) - CastCard(itemId = itemId, service = service, appNavController = appNavController) + CastCard(itemId = itemId, appNavController = appNavController, type = type, mainViewModel = mainViewModel) - SimilarContentCard(itemId = itemId, service = service, mediaType = type, appNavController = appNavController) + SimilarContentCard(itemId = itemId, mediaType = type, appNavController = appNavController, mainViewModel = mainViewModel) - VideosCard(itemId = itemId, service = service, modifier = Modifier.fillMaxWidth()) + VideosCard(itemId = itemId, modifier = Modifier.fillMaxWidth(), mainViewModel = mainViewModel, type = type) - AdditionalDetailsCard(itemId = itemId, mediaItem = mediaItem, service = service, type = type) + AdditionalDetailsCard(mediaItem = mediaItem, type = type) - WatchProvidersCard(itemId = itemId, service = service) + WatchProvidersCard(itemId = itemId, type = type, mainViewModel = mainViewModel) if (windowSize != WindowSizeClass.Expanded) { - ReviewsCard(itemId = itemId, service = service) + ReviewsCard(itemId = itemId, type = type, mainViewModel = mainViewModel) } } @Composable -private fun MiscTvDetails(mediaItem: MutableState, service: TvService) { - mediaItem.value?.let { tv -> +private fun MiscTvDetails( + itemId: Int, + mediaItem: DetailedItem?, + mainViewModel: MainViewModel +) { + mediaItem?.let { tv -> + LaunchedEffect(Unit) { + mainViewModel.getContentRatings(itemId) + } val series = tv as DetailedTv - val contentRating = remember { mutableStateOf("") } - fetchTvContentRating(series.id, service, contentRating) + val contentRatingsMap = remember { mainViewModel.tvContentRatings } + val contentRating = TmdbUtils.getTvRating(contentRatingsMap[itemId]) MiscDetails( modifier = Modifier @@ -497,12 +433,20 @@ private fun MiscTvDetails(mediaItem: MutableState, service: TvSer } @Composable -private fun MiscMovieDetails(mediaItem: MutableState, service: MoviesService) { - mediaItem.value?.let { mi -> +private fun MiscMovieDetails( + itemId: Int, + mediaItem: DetailedItem?, + mainViewModel: MainViewModel +) { + mediaItem?.let { mi -> + LaunchedEffect(Unit) { + mainViewModel.getReleaseDates(itemId) + } + val movie = mi as DetailedMovie - val contentRating = remember { mutableStateOf("") } - fetchMovieContentRating(movie.id, service, contentRating) + val contentRatingsMap = remember { mainViewModel.movieReleaseDates } + val contentRating = TmdbUtils.getMovieRating(contentRatingsMap[itemId]) MiscDetails( modifier = Modifier @@ -523,7 +467,7 @@ private fun MiscDetails( year: String, runtime: String, genres: List, - contentRating: MutableState + contentRating: String ) { Column( modifier = modifier, @@ -541,7 +485,7 @@ private fun MiscDetails( modifier = Modifier.padding(start = 12.dp) ) Text( - text = contentRating.value, + text = contentRating, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(start = 12.dp) ) @@ -556,364 +500,23 @@ private fun MiscDetails( } } -@Composable -private fun ActionsView( - itemId: Int?, - type: MediaViewType, - service: DetailService, - modifier: Modifier = Modifier -) { - itemId?.let { - val session = SessionManager.currentSession.value - Row( - modifier = modifier - .wrapContentSize() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - RateButton( - itemId = itemId, - type = type, - service = service - ) - - if (session?.isAuthorized == true) { - val accountService = AccountService() - WatchlistButton( - itemId = itemId, - type = type - ) - ListButton( - itemId = itemId, - type = type, - service = accountService - ) - FavoriteButton( - itemId = itemId, - type = type - ) - } - } - } -} - -@Composable -fun ActionButton( - modifier: Modifier = Modifier, - itemId: Int, - type: MediaViewType, - imageVector: ImageVector, - contentDescription: String, - isSelected: Boolean, - filledIconColor: Color, - onClick: (Context, Int, MediaViewType, MutableState, (Boolean) -> Unit) -> Unit = { _, _, _, _, _ -> }//(MutableState) -> Unit = { _ -> } -) { - val context = LocalContext.current - val session = SessionManager.currentSession - - val hasSelected = remember { mutableStateOf(isSelected) } - - val bgColor = MaterialTheme.colorScheme.background - val tintColor = remember { Animatable(if (hasSelected.value) filledIconColor else bgColor) } - - val coroutineScope = rememberCoroutineScope() - - Box( - modifier = modifier - .clip(CircleShape) - .height(40.dp) - .requiredWidthIn(min = 40.dp) - .background(color = MaterialTheme.colorScheme.actionButtonColor) - .clickable( - onClick = { - if (session != null) { - onClick(context, itemId, type, hasSelected) { - coroutineScope.launch { - tintColor.animateTo( - targetValue = if (it) filledIconColor else bgColor, - animationSpec = tween(200) - ) - } - } - } - } - ) - ) { - Icon( - modifier = Modifier - .clip(CircleShape) - .align(Alignment.Center), - imageVector = imageVector, - contentDescription = contentDescription, - tint = tintColor.value - ) - } -} - -@Composable -private fun RateButton( - itemId: Int, - type: MediaViewType, - service: DetailService, - modifier: Modifier = Modifier -) { - val session = SessionManager.currentSession.value - val context = LocalContext.current - - val itemIsRated = remember { - mutableStateOf( - if (type == MediaViewType.MOVIE) { - session?.hasRatedMovie(itemId) == true - } else { - session?.hasRatedTvShow(itemId) == true - } - ) - } - - val showSessionDialog = remember { mutableStateOf(false) } - val showRatingDialog = remember { mutableStateOf(false) } - - val bgColor = MaterialTheme.colorScheme.background - val filledColor = RatingSelected - val tintColor = remember { Animatable(if (itemIsRated.value) filledColor else bgColor) } - - val coroutineScope = rememberCoroutineScope() - - Box( - modifier = modifier - .animateContentSize(tween(durationMillis = 100)) - .clip(CircleShape) - .height(40.dp) - .requiredWidthIn(min = 40.dp) - .background(color = MaterialTheme.colorScheme.actionButtonColor) - .clickable( - onClick = { - if (session == null) { - showSessionDialog.value = true - } else { - showRatingDialog.value = true - } - } - ), - ) { - Icon( - modifier = Modifier - .clip(CircleShape) - .align(Alignment.Center), - imageVector = Icons.Filled.Star, - contentDescription = "", - tint = tintColor.value - ) - } - - CreateSessionDialog(showDialog = showSessionDialog, onSessionReturned = {}) - - val userRating = session?.getRatingForId(itemId, type) ?: 0f - RatingDialog(showDialog = showRatingDialog, rating = userRating, onValueConfirmed = { rating -> - if (rating > 0f) { - postRating(context, rating, itemId, service, itemIsRated) - coroutineScope.launch { - tintColor.animateTo(targetValue = filledColor, animationSpec = tween(300)) - } - } else { - deleteRating(context, itemId, service, itemIsRated) - coroutineScope.launch { - tintColor.animateTo(targetValue = bgColor, animationSpec = tween(300)) - } - } - }) -} - -@Composable -fun WatchlistButton( - itemId: Int, - type: MediaViewType, - modifier: Modifier = Modifier -) { - val session = SessionManager.currentSession.value - - val hasWatchlistedItem = if (type == MediaViewType.MOVIE) { - session?.hasWatchlistedMovie(itemId) == true - } else { - session?.hasWatchlistedTvShow(itemId) == true - } - - ActionButton( - modifier = modifier, - itemId = itemId, - type = type, - imageVector = Icons.Filled.Bookmark, - contentDescription = "", - isSelected = hasWatchlistedItem, - filledIconColor = WatchlistSelected, - onClick = ::addToWatchlist - ) -} - -@Composable -fun ListButton( - itemId: Int, - type: MediaViewType, - modifier: Modifier = Modifier, - service: AccountService -) { - val session = SessionManager.currentSession - -// val hasListedItem - - val showSessionDialog = remember { mutableStateOf(false) } - - CircleBackgroundColorImage( - modifier = modifier.clickable( - onClick = { - if (session == null) { - showSessionDialog.value = true - } else { - // add to watchlsit - } - } - ), - size = 40.dp, - backgroundColor = MaterialTheme.colorScheme.actionButtonColor, - image = Icons.Filled.List, - colorFilter = ColorFilter.tint(color = /*if (hasWatchlistedItem.value) WatchlistSelected else*/ MaterialTheme.colorScheme.background), - contentDescription = "" - ) - - CreateSessionDialog(showDialog = showSessionDialog, onSessionReturned = {}) -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -fun FavoriteButton( - itemId: Int, - type: MediaViewType, - modifier: Modifier = Modifier -) { - val session = SessionManager.currentSession.value - val isFavourited = if (type == MediaViewType.MOVIE) { - session?.hasFavoritedMovie(itemId) == true - } else { - session?.hasFavoritedTvShow(itemId) == true - } - - ActionButton( - modifier = modifier, - itemId = itemId, - type = type, - imageVector = Icons.Filled.Favorite, - contentDescription = "", - isSelected = isFavourited, - filledIconColor = FavoriteSelected, - onClick = ::mediaAddToFavorite - ) -} - -@Composable -private fun CreateSessionDialog(showDialog: MutableState, onSessionReturned: (Boolean) -> Unit) { - if (showDialog.value) { - AlertDialog( - modifier = Modifier.wrapContentHeight(), - onDismissRequest = { showDialog.value = false }, - title = { Text(text = stringResource(R.string.sign_in_dialog_title)) }, - confirmButton = {}, - dismissButton = { - TextButton( - modifier = Modifier.height(40.dp), - onClick = { - showDialog.value = false - } - ) { - Text(text = stringResource(R.string.action_cancel)) - } - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - showDialog.value = false - } - ) { - Text(text = stringResource(R.string.action_sign_in)) - } - } - } - ) - } -} - -@Composable -private fun RatingDialog(showDialog: MutableState, rating: Float, onValueConfirmed: (Float) -> Unit) { - - fun formatPosition(position: Float): String { - return DecimalFormat("#.#").format(position.toInt()*5/10f) - } - - if (showDialog.value) { - var sliderPosition by remember { mutableStateOf(rating) } - val formatted = formatPosition(sliderPosition).toFloat() - AlertDialog( - modifier = Modifier.wrapContentHeight(), - onDismissRequest = { showDialog.value = false }, - title = { Text(text = stringResource(R.string.rating_dialog_title)) }, - confirmButton = { - Button( - modifier = Modifier.height(40.dp), - onClick = { - onValueConfirmed.invoke(formatted) - showDialog.value = false - } - ) { - Text( - text = if (formatted > 0f) { - stringResource(id = R.string.rating_dialog_confirm) - } else { - stringResource(id = R.string.rating_dialog_delete) - } - ) - } - }, - dismissButton = { - Button( - modifier = Modifier.height(40.dp), - onClick = { - showDialog.value = false - } - ) { - Text(stringResource(R.string.action_cancel)) - } - }, - text = { - SliderWithLabel( - value = sliderPosition, - valueRange = 0f..20f, - onValueChanged = { - sliderPosition = it - }, - sliderLabel = "${sliderPosition.toInt() * 5}%", - ) - } - ) - } -} - @Composable private fun OverviewCard( modifier: Modifier = Modifier, - itemId: Int?, - mediaItem: MutableState, - service: DetailService + itemId: Int, + mediaItem: DetailedItem?, + type: MediaViewType, + mainViewModel: MainViewModel ) { - val keywordResponse = remember { mutableStateOf(null) } - if (itemId != null) { - if (keywordResponse.value == null) { - fetchKeywords(itemId, service, keywordResponse) - } + LaunchedEffect(Unit) { + mainViewModel.getKeywords(itemId, type) } - mediaItem.value?.let { mi -> - if (!mi.tagline.isNullOrEmpty() || keywordResponse.value?.keywords?.isNotEmpty() == true || !mi.overview.isNullOrEmpty()) { + val keywordsMap = remember { mainViewModel.produceKeywordsFor(type) } + val keywords = keywordsMap[itemId] + + mediaItem?.let { mi -> + if (!mi.tagline.isNullOrEmpty() || keywords?.isNotEmpty() == true || !mi.overview.isNullOrEmpty()) { ContentCard( modifier = modifier ) { @@ -943,7 +546,7 @@ private fun OverviewCard( ) - keywordResponse.value?.keywords?.let { keywords -> + keywords?.let { keywords -> val keywordsChipInfo = keywords.map { ChipInfo(it.name, false) } Row( modifier = Modifier.horizontalScroll(rememberScrollState()), @@ -979,12 +582,10 @@ private fun OverviewCard( @Composable private fun AdditionalDetailsCard( modifier: Modifier = Modifier, - itemId: Int?, - mediaItem: MutableState, - service: DetailService, + mediaItem: DetailedItem?, type: MediaViewType ) { - mediaItem.value?.let { mi -> + mediaItem?.let { mi -> ContentCard( modifier = modifier, title = stringResource(R.string.additional_details_title) @@ -1116,14 +717,20 @@ private fun AdditionalDetailItem( @Composable -private fun CastCard(itemId: Int?, service: DetailService, appNavController: NavController, modifier: Modifier = Modifier) { - val castAndCrew = remember { mutableStateOf(null) } - itemId?.let { - if (castAndCrew.value == null) { - fetchCastAndCrew(itemId, service, castAndCrew) - } +private fun CastCard( + itemId: Int, + type: MediaViewType, + mainViewModel: MainViewModel, + appNavController: NavController, + modifier: Modifier = Modifier +) { + LaunchedEffect(Unit) { + mainViewModel.getCastAndCrew(itemId, type) } + val castMap = remember { mainViewModel.produceCastFor(type) } + val cast = castMap[itemId] + ContentCard( modifier = modifier, title = stringResource(R.string.cast_label), @@ -1139,9 +746,10 @@ private fun CastCard(itemId: Int?, service: DetailService, appNavController: Nav item { Spacer(modifier = Modifier.width(8.dp)) } - items(castAndCrew.value?.cast?.size ?: 0) { i -> - val castMember = castAndCrew.value!!.cast[i] - CastCrewCard(appNavController = appNavController, person = castMember) + items(cast?.size ?: 0) { i -> + cast?.get(i)?.let { + CastCrewCard(appNavController = appNavController, person = it) + } } item { Spacer(modifier = Modifier.width(8.dp)) @@ -1175,19 +783,20 @@ private fun CastCrewCard(appNavController: NavController, person: Person) { @Composable fun SimilarContentCard( - itemId: Int?, - service: DetailService, + itemId: Int, mediaType: MediaViewType, + mainViewModel: MainViewModel, appNavController: NavController, modifier: Modifier = Modifier ) { - val similarContent = remember { mutableStateOf(null) } - itemId?.let { - if (similarContent.value == null) { - fetchSimilarContent(itemId, service, similarContent) - } + LaunchedEffect(Unit) { + mainViewModel.getSimilar(itemId, mediaType) } + val similarContentMap = remember { mainViewModel.produceSimilarContentFor(mediaType) } + val similarContent = similarContentMap[itemId] + val pagingItems = similarContent?.collectAsLazyPagingItems() + ContentCard( modifier = modifier, title = stringResource(id = R.string.recommended_label) @@ -1202,22 +811,24 @@ fun SimilarContentCard( item { Spacer(modifier = Modifier.width(8.dp)) } - items(similarContent.value?.results?.size ?: 0) { i -> - val content = similarContent.value!!.results[i] - - TwoLineImageTextCard( - title = content.title, - modifier = Modifier - .width(124.dp) - .wrapContentHeight(), - imageUrl = TmdbUtils.getFullPosterPath(content), - onItemClicked = { - appNavController.navigate( - AppNavItem.DetailView.withArgs(mediaType, content.id) + pagingItems?.let { + lazyPagingItems(it) { item -> + item?.let { + TwoLineImageTextCard( + title = item.title, + modifier = Modifier + .width(124.dp) + .wrapContentHeight(), + imageUrl = TmdbUtils.getFullPosterPath(item), + onItemClicked = { + appNavController.navigate( + AppNavItem.DetailView.withArgs(mediaType, item.id) + ) + }, + placeholder = Icons.Filled.Movie ) - }, - placeholder = Icons.Filled.Movie - ) + } + } } item { Spacer(modifier = Modifier.width(8.dp)) @@ -1228,19 +839,19 @@ fun SimilarContentCard( @Composable fun VideosCard( - itemId: Int?, - service: DetailService, + itemId: Int, + type: MediaViewType, + mainViewModel: MainViewModel, modifier: Modifier = Modifier ) { - val videoResponse = remember { mutableStateOf(null) } - itemId?.let { - if (videoResponse.value == null) { - fetchVideos(itemId, service, videoResponse) - } + LaunchedEffect(Unit) { + mainViewModel.getVideos(itemId, type) } - if (videoResponse.value != null && videoResponse.value!!.results.any { it.isOfficial }) { - val results = videoResponse.value!!.results + val videosMap = remember { mainViewModel.produceVideosFor(type) } + val videos = videosMap[itemId] + + if (videos?.any { it.isOfficial } == true) { ExpandableContentCard( modifier = modifier, title = { @@ -1254,7 +865,7 @@ fun VideosCard( toggleTextColor = MaterialTheme.colorScheme.primary ) { isExpanded -> VideoGroup( - results = results, + results = videos, type = Video.Type.TRAILER, title = stringResource(id = Video.Type.TRAILER.stringRes) ) @@ -1262,7 +873,7 @@ fun VideosCard( if (isExpanded) { Video.Type.values().filter { it != Video.Type.TRAILER }.forEach { type -> VideoGroup( - results = results, + results = videos, type = type, title = stringResource(id = type.stringRes) ) @@ -1309,24 +920,18 @@ private fun VideoGroup(results: List