refactor data storage model to viewmodels

This commit is contained in:
Owen LeJeune
2023-06-20 21:15:34 -04:00
parent 3c2839f608
commit 17e3ad32f0
38 changed files with 2062 additions and 1643 deletions

View File

@@ -0,0 +1,11 @@
package com.owenlejeune.tvtime.api
import retrofit2.Response
infix fun <T> Response<T>.storedIn(body: (T) -> Unit) {
if (isSuccessful) {
body()?.let {
body(it)
}
}
}

View File

@@ -4,7 +4,13 @@ import androidx.compose.ui.text.intl.Locale
import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.BuildConfig
import com.owenlejeune.tvtime.api.Client import com.owenlejeune.tvtime.api.Client
import com.owenlejeune.tvtime.api.QueryParam 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.AccountV4Api
import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Api import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Api
import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Api import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Api
@@ -53,10 +59,6 @@ class TmdbClient: KoinComponent {
return clientV4.create(AuthenticationV4Api::class.java) return clientV4.create(AuthenticationV4Api::class.java)
} }
fun createGuestSessionService(): GuestSessionApi {
return client.create(GuestSessionApi::class.java)
}
fun createAccountService(): AccountApi { fun createAccountService(): AccountApi {
return client.create(AccountApi::class.java) return client.create(AccountApi::class.java)
} }

View File

@@ -1,17 +1,52 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 package com.owenlejeune.tvtime.api.tmdb.api.v3
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import android.util.Log
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* 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 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<AccountDetails> { suspend fun getAccountDetails(): Response<AccountDetails> {
return accountService.getAccountDetails() 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<FavoriteMediaResponse<FavoriteMovie>> { suspend fun getFavoriteMovies(accountId: Int, page: Int = 1): Response<FavoriteMediaResponse<FavoriteMovie>> {
return accountService.getFavoriteMovies(accountId, page) return accountService.getFavoriteMovies(accountId, page)
} }
@@ -20,10 +55,6 @@ class AccountService {
return accountService.getFavoriteTvShows(accountId, page) return accountService.getFavoriteTvShows(accountId, page)
} }
suspend fun markAsFavorite(accountId: Int, body: MarkAsFavoriteBody): Response<StatusResponse> {
return accountService.markAsFavorite(accountId, body)
}
suspend fun getRatedMovies(accountId: Int, page: Int = 1): Response<RatedMediaResponse<RatedMovie>> { suspend fun getRatedMovies(accountId: Int, page: Int = 1): Response<RatedMediaResponse<RatedMovie>> {
return accountService.getRatedMovies(accountId, page) return accountService.getRatedMovies(accountId, page)
} }
@@ -44,8 +75,4 @@ class AccountService {
return accountService.getTvWatchlist(accountId, page) return accountService.getTvWatchlist(accountId, page)
} }
suspend fun addToWatchlist(accountId: Int, body: WatchlistBody): Response<StatusResponse> {
return accountService.addToWatchlist(accountId, body)
}
} }

View File

@@ -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<ConfigurationCountry>()
val jobsConfiguration = mutableStateListOf<ConfigurationJob>()
val languagesConfiguration = mutableStateListOf<ConfigurationLanguage>()
val primaryTranslationsConfiguration = mutableStateListOf<String>()
val timezonesConfiguration = mutableStateListOf<ConfigurationTimezone>()
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")
}
}
}

View File

@@ -5,26 +5,28 @@ import retrofit2.Response
interface DetailService { interface DetailService {
suspend fun getById(id: Int): Response<out DetailedItem> suspend fun getById(id: Int)
suspend fun getImages(id: Int): Response<ImageCollection> suspend fun getImages(id: Int)
suspend fun getCastAndCrew(id: Int): Response<CastAndCrew> suspend fun getCastAndCrew(id: Int)
suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse> suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse>
suspend fun getVideos(id: Int): Response<VideoResponse> suspend fun getVideos(id: Int)
suspend fun getReviews(id: Int): Response<ReviewResponse> suspend fun getReviews(id: Int)
suspend fun postRating(id: Int, ratingBody: RatingBody): Response<StatusResponse> suspend fun postRating(id: Int, ratingBody: RatingBody)
suspend fun deleteRating(id: Int): Response<StatusResponse> suspend fun deleteRating(id: Int)
suspend fun getKeywords(id: Int): Response<KeywordsResponse> suspend fun getKeywords(id: Int)
suspend fun getWatchProviders(id: Int): Response<WatchProviderResponse> suspend fun getWatchProviders(id: Int)
suspend fun getExternalIds(id: Int): Response<ExternalIds> suspend fun getExternalIds(id: Int)
suspend fun getAccountStates(id: Int)
} }

View File

@@ -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<RatedMediaResponse<RatedMovie>>
@GET("guest_session/{session_id}/rated/tv")
suspend fun getRatedTvShows(@Path("session_id") sessionId: String): Response<RatedMediaResponse<RatedTv>>
@GET("guest_session/{session_id}/rated/tv/episodes")
suspend fun getRatedTvEpisodes(@Path("session_id") sessionId: String): Response<RatedMediaResponse<RatedEpisode>>
}

View File

@@ -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<RatedMediaResponse<RatedMovie>> {
return service.getRatedMovies(sessionId = sessionId)
}
suspend fun getRatedTvShows(sessionId: String): Response<RatedMediaResponse<RatedTv>> {
return service.getRatedTvShows(sessionId = sessionId)
}
suspend fun getRatedTvEpisodes(sessionId: String): Response<RatedMediaResponse<RatedEpisode>> {
return service.getRatedTvEpisodes(sessionId = sessionId)
}
}

View File

@@ -61,4 +61,7 @@ interface MoviesApi {
@GET("movie/{id}/external_ids") @GET("movie/{id}/external_ids")
suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds> suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds>
@GET("movie/{id}/account_states")
suspend fun getAccountStates(@Path("id") id: Int, @Query("session_id") sessionId: String): Response<AccountStates>
} }

View File

@@ -1,25 +1,146 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 package com.owenlejeune.tvtime.api.tmdb.api.v3
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import android.util.Log
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastAndCrew import androidx.compose.runtime.mutableStateMapOf
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem 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.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse 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.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.MovieReleaseResults
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody 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.StatusResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse 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 com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Response import retrofit2.Response
import java.util.Collections
import java.util.Locale
class MoviesService: KoinComponent, DetailService, HomePageService { 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<Int, DetailedMovie>())
val images = Collections.synchronizedMap(mutableStateMapOf<Int, ImageCollection>())
val cast = Collections.synchronizedMap(mutableStateMapOf<Int, List<CastMember>>())
val crew = Collections.synchronizedMap(mutableStateMapOf<Int, List<CrewMember>>())
val videos = Collections.synchronizedMap(mutableStateMapOf<Int, List<Video>>())
val reviews = Collections.synchronizedMap(mutableStateMapOf<Int, List<Review>>())
val keywords = Collections.synchronizedMap(mutableStateMapOf<Int, List<Keyword>>())
val watchProviders = Collections.synchronizedMap(mutableStateMapOf<Int, WatchProviders>())
val externalIds = Collections.synchronizedMap(mutableStateMapOf<Int, ExternalIds>())
val releaseDates = Collections.synchronizedMap(mutableStateMapOf<Int, List<MovieReleaseResults.ReleaseDateResult>>())
val similar = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<TmdbItem>>>())
val accountStates = Collections.synchronizedMap(mutableStateMapOf<Int, AccountStates>())
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<out HomePageResponse> {
return movieService.getSimilarMovies(id, page)
}
override suspend fun getPopular(page: Int): Response<out HomePageResponse> { override suspend fun getPopular(page: Int): Response<out HomePageResponse> {
return movieService.getPopularMovies(page) return movieService.getPopularMovies(page)
@@ -36,55 +157,42 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
override suspend fun getUpcoming(page: Int): Response<out HomePageResponse> { override suspend fun getUpcoming(page: Int): Response<out HomePageResponse> {
return movieService.getUpcomingMovies(page) return movieService.getUpcomingMovies(page)
} }
}
suspend fun getReleaseDates(id: Int): Response<MovieReleaseResults> { class SimilarMoviesSource(private val movieId: Int): PagingSource<Int, TmdbItem>(), KoinComponent {
return movieService.getReleaseDates(id)
private val service: MoviesService by inject()
override fun getRefreshKey(state: PagingState<Int, TmdbItem>): Int? {
return state.anchorPosition
} }
override suspend fun getById(id: Int): Response<out DetailedItem> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TmdbItem> {
return movieService.getMovieById(id) return try {
} val nextPage = params.key ?: 1
val response = service.getSimilar(movieId, nextPage)
override suspend fun getImages(id: Int): Response<ImageCollection> { if (response.isSuccessful) {
return movieService.getMovieImages(id) val responseBody = response.body()
} val result = responseBody?.results ?: emptyList()
LoadResult.Page(
override suspend fun getCastAndCrew(id: Int): Response<CastAndCrew> { data = result,
return movieService.getCastAndCrew(id) prevKey = if (nextPage == 1) {
} null
} else {
override suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse> { nextPage - 1
return movieService.getSimilarMovies(id, page) },
} nextKey = if (result.isEmpty()) {
null
override suspend fun getVideos(id: Int): Response<VideoResponse> { } else {
return movieService.getVideos(id) responseBody?.page?.plus(1) ?: (nextPage + 1)
} }
)
override suspend fun getReviews(id: Int): Response<ReviewResponse> { } else {
return movieService.getReviews(id) LoadResult.Invalid()
} }
} catch (e: Exception) {
override suspend fun postRating(id: Int, rating: RatingBody): Response<StatusResponse> { return LoadResult.Error(e)
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<StatusResponse> {
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<KeywordsResponse> {
return movieService.getKeywords(id)
}
override suspend fun getWatchProviders(id: Int): Response<WatchProviderResponse> {
return movieService.getWatchProviders(id)
}
override suspend fun getExternalIds(id: Int): Response<ExternalIds> {
return movieService.getExternalIds(id)
} }
} }

View File

@@ -1,36 +1,93 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 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.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.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.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse 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.PersonCreditsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImage
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection
import okhttp3.internal.notify
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import retrofit2.Response import retrofit2.Response
import java.util.Collections
class PeopleService: KoinComponent { class PeopleService: KoinComponent {
private val TAG = "PeopleService"
private val service by lazy { TmdbClient().createPeopleService() } private val service by lazy { TmdbClient().createPeopleService() }
suspend fun getPerson(id: Int): Response<DetailPerson> { val peopleMap = Collections.synchronizedMap(mutableStateMapOf<Int, DetailPerson>())
return service.getPerson(id) val castMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<DetailCast>>())
val crewMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<DetailCrew>>())
val imagesMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<PersonImage>>())
val externalIdsMap = Collections.synchronizedMap(mutableStateMapOf<Int, ExternalIds>())
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<PersonCreditsResponse> { suspend fun getCredits(id: Int) {
return service.getCredits(id) 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<PersonImageCollection> { suspend fun getImages(id: Int) {
return service.getImages(id) 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<HomePagePeopleResponse> { 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<HomePagePeopleResponse> {
return service.getPopular(page) return service.getPopular(page)
} }
suspend fun getExternalIds(id: Int): Response<ExternalIds> {
return service.getExternalIds(id)
}
} }

View File

@@ -1,46 +1,98 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 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 com.owenlejeune.tvtime.api.tmdb.api.v3.model.Collection
import kotlinx.coroutines.CoroutineScope import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword
import kotlinx.coroutines.Dispatchers import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ProductionCompany
import kotlinx.coroutines.launch import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import kotlinx.coroutines.withContext 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import retrofit2.Response import retrofit2.Response
import java.util.*
class SearchService: KoinComponent { class SearchService: KoinComponent {
private val service: SearchApi by inject() private val service: SearchService by inject()
suspend fun searchCompanies(query: String, page: Int = 1): Response<SearchResult<ProductionCompany>> { val movieResults = mutableStateOf<Flow<PagingData<SearchResultMovie>>?>(null)
val tvResults = mutableStateOf<Flow<PagingData<SearchResultTv>>?>(null)
val peopleResults = mutableStateOf<Flow<PagingData<SearchResultPerson>>?>(null)
val multiResults = mutableStateOf<Flow<PagingData<SortableSearchResult>>?>(null)
fun searchCompanies(query: String, page: Int = 1): Response<SearchResult<ProductionCompany>> {
return service.searchCompanies(query, page) return service.searchCompanies(query, page)
} }
suspend fun searchCollections(query: String, page: Int = 1): Response<SearchResult<Collection>> { fun searchCollections(query: String, page: Int = 1): Response<SearchResult<Collection>> {
return service.searchCollections(query, page) return service.searchCollections(query, page)
} }
suspend fun searchKeywords(query: String, page: Int = 1): Response<SearchResult<Keyword>> { fun searchKeywords(query: String, page: Int = 1): Response<SearchResult<Keyword>> {
return service.searchKeywords(query, page) return service.searchKeywords(query, page)
} }
suspend fun searchMovies(query: String, page: Int = 1): Response<SearchResult<SearchResultMovie>> { fun searchMovies(query: String, page: Int): Response<SearchResult<SearchResultMovie>> {
return service.searchMovies(query, page) return service.searchMovies(query, page)
} }
suspend fun searchTv(query: String, page: Int = 1): Response<SearchResult<SearchResultTv>> { fun searchTv(query: String, page: Int): Response<SearchResult<SearchResultTv>> {
return service.searchTv(query, page) return service.searchTv(query, page)
} }
suspend fun searchPeople(query: String, page: Int = 1): Response<SearchResult<SearchResultPerson>> { fun searchPeople(query: String, page: Int): Response<SearchResult<SearchResultPerson>> {
return service.searchPeople(query, page) return service.searchPeople(query, page)
} }
suspend fun searchMulti(query: String, page: Int = 1): Response<SearchResult<SortableSearchResult>> { fun searchMulti(query: String, page: Int): Response<SearchResult<SortableSearchResult>> {
return service.searchMulti(query, page) return service.searchMulti(query, page)
} }
}
typealias SearchResultProvider<T> = suspend (Int) -> Response<SearchResult<T>>
class SearchPagingSource<T: Searchable>(
private val provideResults: SearchResultProvider<T>
): PagingSource<Int, T>(), KoinComponent {
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
return state.anchorPosition
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
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)
}
}
} }

View File

@@ -64,4 +64,7 @@ interface TvApi {
@GET("tv/{id}/external_ids") @GET("tv/{id}/external_ids")
suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds> suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds>
@GET("tv/{id}/account_states")
suspend fun getAccountStates(@Path("id") id: Int): Response<AccountStates>
} }

View File

@@ -1,26 +1,148 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 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.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.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.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.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse 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.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.KeywordsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody 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.ReviewResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season 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.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.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.VideoResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse 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 com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Response import retrofit2.Response
import java.util.Collections
import java.util.Locale
class TvService: KoinComponent, DetailService, HomePageService { 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<Int, DetailedTv>())
val images = Collections.synchronizedMap(mutableStateMapOf<Int, ImageCollection>())
val cast = Collections.synchronizedMap(mutableStateMapOf<Int, List<CastMember>>())
val crew = Collections.synchronizedMap(mutableStateMapOf<Int, List<CrewMember>>())
val videos = Collections.synchronizedMap(mutableStateMapOf<Int, List<Video>>())
val reviews = Collections.synchronizedMap(mutableStateMapOf<Int, List<Review>>())
val keywords = Collections.synchronizedMap(mutableStateMapOf<Int, List<Keyword>>())
val watchProviders = Collections.synchronizedMap(mutableStateMapOf<Int, WatchProviders>())
val externalIds = Collections.synchronizedMap(mutableStateMapOf<Int, ExternalIds>())
val contentRatings = Collections.synchronizedMap(mutableStateMapOf<Int, List<TvContentRatings.TvContentRating>>())
val similar = Collections.synchronizedMap(mutableStateMapOf<Int, Flow<PagingData<TmdbItem>>>())
val accountStates = Collections.synchronizedMap(mutableStateMapOf<Int, AccountStates>())
private val _seasons = Collections.synchronizedMap(mutableStateMapOf<Int, MutableSet<Season>>())
val seasons: MutableMap<Int, out Set<Season>>
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<out HomePageResponse> {
return service.getSimilarTvShows(id, page)
}
override suspend fun getPopular(page: Int): Response<out HomePageResponse> { override suspend fun getPopular(page: Int): Response<out HomePageResponse> {
return service.getPoplarTv(page) return service.getPoplarTv(page)
@@ -38,58 +160,42 @@ class TvService: KoinComponent, DetailService, HomePageService {
return service.getTvOnTheAir(page) return service.getTvOnTheAir(page)
} }
override suspend fun getById(id: Int): Response<out DetailedItem> { }
return service.getTvShowById(id)
class SimilarTvSource(private val tvId: Int): PagingSource<Int, TmdbItem>(), KoinComponent {
private val service: TvService by inject()
override fun getRefreshKey(state: PagingState<Int, TmdbItem>): Int? {
return state.anchorPosition
} }
override suspend fun getImages(id: Int): Response<ImageCollection> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TmdbItem> {
return service.getTvImages(id) return try {
} val nextPage = params.key ?: 1
val response = service.getSimilar(tvId, nextPage)
override suspend fun getCastAndCrew(id: Int): Response<CastAndCrew> { if (response.isSuccessful) {
return service.getCastAndCrew(id) val responseBody = response.body()
} val result = responseBody?.results ?: emptyList()
LoadResult.Page(
suspend fun getContentRatings(id: Int): Response<TvContentRatings> { data = result,
return service.getContentRatings(id) prevKey = if (nextPage == 1) {
} null
} else {
override suspend fun getSimilar(id: Int, page: Int): Response<out HomePageResponse> { nextPage - 1
return service.getSimilarTvShows(id, page) },
} nextKey = if (result.isEmpty()) {
null
override suspend fun getVideos(id: Int): Response<VideoResponse> { } else {
return service.getVideos(id) responseBody?.page?.plus(1) ?: (nextPage + 1)
} }
)
override suspend fun getReviews(id: Int): Response<ReviewResponse> { } else {
return service.getReviews(id) LoadResult.Invalid()
} }
} catch (e: Exception) {
override suspend fun postRating(id: Int, ratingBody: RatingBody): Response<StatusResponse> { return LoadResult.Error(e)
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<StatusResponse> {
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<KeywordsResponse> {
return service.getKeywords(id)
}
override suspend fun getWatchProviders(id: Int): Response<WatchProviderResponse> {
return service.getWatchProviders(id)
}
suspend fun getSeason(seriesId: Int, seasonId: Int): Response<Season> {
return service.getSeason(seriesId, seasonId)
}
override suspend fun getExternalIds(id: Int): Response<ExternalIds> {
return service.getExternalIds(id)
} }
} }

View File

@@ -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<AccountStates>() {
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)
}
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -12,8 +12,7 @@ import org.koin.core.component.inject
class HomePagePeoplePagingSource: PagingSource<Int, HomePagePerson>(), KoinComponent { class HomePagePeoplePagingSource: PagingSource<Int, HomePagePerson>(), KoinComponent {
private val service: PeopleApi by inject() private val service: PeopleService by inject()
private val context: Context by inject()
override fun getRefreshKey(state: PagingState<Int, HomePagePerson>): Int? { override fun getRefreshKey(state: PagingState<Int, HomePagePerson>): Int? {
return state.anchorPosition return state.anchorPosition
@@ -32,7 +31,6 @@ class HomePagePeoplePagingSource: PagingSource<Int, HomePagePerson>(), KoinCompo
nextKey = if (results.isEmpty() || responseBody == null) null else responseBody.page + 1 nextKey = if (results.isEmpty() || responseBody == null) null else responseBody.page + 1
) )
} else { } else {
// Toast.makeText(context, "No more results found", Toast.LENGTH_SHORT).show()
LoadResult.Invalid() LoadResult.Invalid()
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -7,11 +7,7 @@ import retrofit2.http.*
interface ListV4Api { interface ListV4Api {
@GET("list/{id}") @GET("list/{id}")
suspend fun getList( suspend fun getList(@Path("id") listId: Int): Response<MediaList>
@Path("id") listId: Int,
@Query("api_key") apiKey: String,
@Query("page") page: Int = 1
): Response<MediaList>
@POST("list") @POST("list")
suspend fun createList(@Body body: CreateListBody): Response<CreateListResponse> suspend fun createList(@Body body: CreateListBody): Response<CreateListResponse>

View File

@@ -1,52 +1,74 @@
package com.owenlejeune.tvtime.api.tmdb.api.v4 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.BuildConfig
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.* import com.owenlejeune.tvtime.api.tmdb.api.v4.model.*
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import retrofit2.Response import retrofit2.Response
class ListV4Service: KoinComponent { class ListV4Service: KoinComponent {
private val service by lazy { TmdbClient().createV4ListService() } companion object {
private const val TAG = "ListV4Service"
private val preferences: AppPreferences by inject()
suspend fun getList(listId: Int, page: Int = 1): Response<MediaList> {
return service.getList(listId, BuildConfig.TMDB_Api_v4Key, page)
} }
suspend fun createList(body: CreateListBody): Response<CreateListResponse> { private val service: ListV4Api by inject()
return service.createList(body)
val listMap = mutableStateMapOf<Int, MediaList>()
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<StatusResponse> { suspend fun createList(body: CreateListBody) {//}: Response<CreateListResponse> {
return service.updateList(listId, body) service.createList(body)
} }
suspend fun clearList(listId: Int): Response<ClearListResponse> { suspend fun updateList(listId: Int, body: ListUpdateBody) {
return service.clearList(listId) 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<StatusResponse> { suspend fun deleteListItems(listId: Int, body: DeleteListItemsBody) {
return service.deleteList(listId) 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<AddToListResponse> { suspend fun clearList(listId: Int) {//}: Response<ClearListResponse> {
return service.addItemsToList(listId, body) service.clearList(listId)
} }
suspend fun updateListItems(listId: Int, body: UpdateListItemBody): Response<AddToListResponse> { suspend fun deleteList(listId: Int) {//}: Response<StatusResponse> {
return service.updateListItems(listId, body) service.deleteList(listId)
} }
suspend fun deleteListItems(listId: Int, body: DeleteListItemsBody): Response<AddToListResponse> { suspend fun addItemsToList(listId: Int, body: AddToListBody) {//}: Response<AddToListResponse> {
return service.deleteListItems(listId, body) service.addItemsToList(listId, body)
} }
suspend fun getListItemStatus(listId: Int, mediaId: Int, mediaType: String): Response<ListItemStatusResponse> { suspend fun updateListItems(listId: Int, body: UpdateListItemBody) {//}: Response<AddToListResponse> {
return service.getListItemStatus(listId, mediaId, mediaType) service.updateListItems(listId, body)
}
suspend fun getListItemStatus(listId: Int, mediaId: Int, mediaType: String) {//}: Response<ListItemStatusResponse> {
service.getListItemStatus(listId, mediaId, mediaType)
} }
} }

View File

@@ -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<Int, PopularMovie>() {
//
// 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, PopularMovie>): Int? {
// return state.anchorPosition
// }
//
// override suspend fun load(params: LoadParams<Int>): LoadResult<Int, PopularMovie> {
// 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)
// }
// }
//}

View File

@@ -6,11 +6,21 @@ import com.owenlejeune.tvtime.BuildConfig
import com.owenlejeune.tvtime.api.* import com.owenlejeune.tvtime.api.*
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService 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.KnownForDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SortableSearchResultDeserializer 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.KnownFor
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult 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.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.deserializer.ListItemDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
@@ -30,7 +40,6 @@ val networkModule = module {
single { get<TmdbClient>().createV4AccountService() } single { get<TmdbClient>().createV4AccountService() }
single { get<TmdbClient>().createV4ListService() } single { get<TmdbClient>().createV4ListService() }
single { get<TmdbClient>().createAccountService() } single { get<TmdbClient>().createAccountService() }
single { get<TmdbClient>().createGuestSessionService() }
single { get<TmdbClient>().createAuthenticationService() } single { get<TmdbClient>().createAuthenticationService() }
single { get<TmdbClient>().createMovieService() } single { get<TmdbClient>().createMovieService() }
single { get<TmdbClient>().createPeopleService() } single { get<TmdbClient>().createPeopleService() }
@@ -38,14 +47,23 @@ val networkModule = module {
single { get<TmdbClient>().createTvService() } single { get<TmdbClient>().createTvService() }
single { get<TmdbClient>().createConfigurationService() } single { get<TmdbClient>().createConfigurationService() }
single { ConfigurationService() }
single { MoviesService() }
single { TvService() }
single { AccountService() } single { AccountService() }
single { AuthenticationService() }
single { PeopleService() }
single { SearchService() }
single { AccountV4Service() } single { AccountV4Service() }
single { AuthenticationV4Service() }
single { ListV4Service() }
single<Map<Class<*>, JsonDeserializer<*>>> { single<Map<Class<*>, JsonDeserializer<*>>> {
mapOf( mapOf(
ListItem::class.java to ListItemDeserializer(), ListItem::class.java to ListItemDeserializer(),
KnownFor::class.java to KnownForDeserializer(), KnownFor::class.java to KnownForDeserializer(),
SortableSearchResult::class.java to SortableSearchResultDeserializer() SortableSearchResult::class.java to SortableSearchResultDeserializer(),
AccountStates::class.java to AccountStatesDeserializer()
) )
} }

View File

@@ -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() }
}

View File

@@ -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<Actions> = listOf(Actions.RATE, Actions.WATCHLIST, Actions.LIST, Actions.FAVORITE),
modifier: Modifier = Modifier
) {
val accountViewModel = viewModel<AccountViewModel>()
val mainViewModel = viewModel<MainViewModel>()
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)
}
}
)
}

View File

@@ -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<Boolean>,
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}%"
)
}
)
}
}

View File

@@ -19,10 +19,11 @@ fun SliderWithLabel(
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
onValueChanged: (Float) -> Unit, onValueChanged: (Float) -> Unit,
sliderLabel: String, sliderLabel: String,
modifier: Modifier = Modifier,
steps: Int = 0, steps: Int = 0,
labelMinWidth: Dp = 36.dp labelMinWidth: Dp = 46.dp
) { ) {
Column { Column(modifier = modifier) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -60,7 +61,7 @@ fun SliderWithLabel(
@Composable @Composable
fun SliderLabel(label: String, minWidth: Dp, modifier: Modifier = Modifier) { fun SliderLabel(label: String, minWidth: Dp, modifier: Modifier = Modifier) {
Text( Text(
label, text = label,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
modifier = modifier modifier = modifier

View File

@@ -36,9 +36,6 @@ sealed class AccountTabNavItem(
val noContentText = resourceUtils.getString(noContentStringRes) val noContentText = resourceUtils.getString(noContentStringRes)
companion object { companion object {
val GuestItems
get() = listOf(RatedMovies, RatedTvShows, RatedTvEpisodes)
val AuthorizedItems val AuthorizedItems
get() = listOf( get() = listOf(
RatedMovies, RatedTvShows, RatedTvEpisodes, FavoriteMovies, FavoriteTvShows, RatedMovies, RatedTvShows, RatedTvEpisodes, FavoriteMovies, FavoriteTvShows,

View File

@@ -1,4 +1,5 @@
package com.owenlejeune.tvtime.ui.screens package com.owenlejeune.tvtime.ui.screens
import android.accounts.Account
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
@@ -35,6 +36,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.Dimension
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.accompanist.systemuicontroller.rememberSystemUiController 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.WindowSizeClass
import com.owenlejeune.tvtime.extensions.unlessEmpty import com.owenlejeune.tvtime.extensions.unlessEmpty
import com.owenlejeune.tvtime.preferences.AppPreferences 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.RatingView
import com.owenlejeune.tvtime.ui.components.Spinner import com.owenlejeune.tvtime.ui.components.Spinner
import com.owenlejeune.tvtime.ui.components.SwitchPreference import com.owenlejeune.tvtime.ui.components.SwitchPreference
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.theme.* import com.owenlejeune.tvtime.ui.theme.*
import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
@@ -68,22 +74,22 @@ import kotlin.math.roundToInt
@Composable @Composable
fun ListDetailScreen( fun ListDetailScreen(
appNavController: NavController, appNavController: NavController,
itemId: Int?, itemId: Int,
windowSize: WindowSizeClass, windowSize: WindowSizeClass,
preferences: AppPreferences = KoinJavaComponent.get(AppPreferences::class.java) service: ListV4Service = KoinJavaComponent.get(ListV4Service::class.java)
) { ) {
val accountViewModel = viewModel<AccountViewModel>()
LaunchedEffect(Unit) {
accountViewModel.getList(itemId)
}
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background)
systemUiController.setNavigationBarColor(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<MediaList?>(null) }
itemId?.let {
if (parentList.value == null) {
fetchList(itemId, service, parentList)
}
}
val decayAnimationSpec = rememberSplineBasedDecay<Float>() val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val topAppBarScrollState = rememberTopAppBarScrollState() val topAppBarScrollState = rememberTopAppBarScrollState()
@@ -101,7 +107,7 @@ fun ListDetailScreen(
scrolledContainerColor = MaterialTheme.colorScheme.background, scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.primary titleContentColor = MaterialTheme.colorScheme.primary
), ),
title = { Text(text = parentList.value?.name ?: "") }, title = { Text(text = parentList?.name ?: "") },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { appNavController.popBackStack() } onClick = { appNavController.popBackStack() }
@@ -117,7 +123,7 @@ fun ListDetailScreen(
} }
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { Box(modifier = Modifier.padding(innerPadding)) {
parentList.value?.let { mediaList -> parentList?.let { mediaList ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(all = 12.dp) .padding(all = 12.dp)
@@ -151,7 +157,7 @@ private fun ListHeader(
list: MediaList, list: MediaList,
selectedSortOrder: MutableState<SortOrder>, selectedSortOrder: MutableState<SortOrder>,
service: ListV4Service, service: ListV4Service,
parentList: MutableState<MediaList?> parentList: MediaList?
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -254,10 +260,7 @@ private fun ListHeader(
if (showEditListDialog.value) { if (showEditListDialog.value) {
EditListDialog( EditListDialog(
showEditListDialog = showEditListDialog, showEditListDialog = showEditListDialog,
list = list, list = list
service = service,
parentList = parentList,
selectedSortOrder = selectedSortOrder
) )
} }
} }
@@ -312,11 +315,10 @@ private fun SortOrderDialog(
@Composable @Composable
private fun EditListDialog( private fun EditListDialog(
showEditListDialog: MutableState<Boolean>, showEditListDialog: MutableState<Boolean>,
list: MediaList, list: MediaList
service: ListV4Service,
parentList: MutableState<MediaList?>,
selectedSortOrder: MutableState<SortOrder>
) { ) {
val accountViewModel = viewModel<AccountViewModel>()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var listTitle by remember { mutableStateOf(list.name) } var listTitle by remember { mutableStateOf(list.name) }
@@ -338,11 +340,7 @@ private fun EditListDialog(
onClick = { onClick = {
val listUpdateBody = ListUpdateBody(listTitle, listDescription, isPublicList, editSelectedSortOrder) val listUpdateBody = ListUpdateBody(listTitle, listDescription, isPublicList, editSelectedSortOrder)
coroutineScope.launch { coroutineScope.launch {
val response = service.updateList(list.id, listUpdateBody) accountViewModel.updateList(list.id, listUpdateBody)
if (response.isSuccessful) {
fetchList(list.id, service, parentList)
selectedSortOrder.value = editSelectedSortOrder
}
showEditListDialog.value = false showEditListDialog.value = false
} }
} }
@@ -426,23 +424,24 @@ private fun RowScope.OverviewStatCard(
private fun ListItemView( private fun ListItemView(
appNavController: NavController, appNavController: NavController,
listItem: ListItem, listItem: ListItem,
list: MutableState<MediaList?> list: MediaList?
) { ) {
val context = LocalContext.current val accountViewModel = viewModel<AccountViewModel>()
val scope = rememberCoroutineScope()
RevealSwipe ( RevealSwipe (
directions = setOf(RevealDirection.EndToStart), directions = setOf(RevealDirection.EndToStart),
hiddenContentEnd = { hiddenContentEnd = {
IconButton( IconButton(
modifier = Modifier.padding(horizontal = 15.dp), modifier = Modifier.padding(horizontal = 15.dp),
onClick = { onClick = {
removeItemFromList( scope.launch {
context = context, accountViewModel.deleteListItem(
itemId = listItem.id, list?.id ?: -1,
itemType = listItem.mediaType, listItem.id,
itemName = listItem.title, listItem.mediaType
service = ListV4Service(), )
list = list }
)
} }
) { ) {
Icon( Icon(
@@ -533,7 +532,11 @@ private fun ListItemView(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold 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)) 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<Boolean>,
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<Boolean>,
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) { private fun shareListUrl(context: Context, listId: Int) {
val shareUrl = "https://www.themoviedb.org/list/$listId" val shareUrl = "https://www.themoviedb.org/list/$listId"
val sendIntent = Intent().apply { val sendIntent = Intent().apply {
@@ -665,46 +565,4 @@ private fun shareListUrl(context: Context, listId: Int) {
} }
val shareIntent = Intent.createChooser(sendIntent, null) val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent) context.startActivity(shareIntent)
}
private fun fetchList(
itemId: Int,
service: ListV4Service,
listItem: MutableState<MediaList?>
) {
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<MediaList?>
) {
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()
}
}
} }

View File

@@ -2,17 +2,29 @@ package com.owenlejeune.tvtime.ui.screens
import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarScrollState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,43 +32,40 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.PeopleService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse
import com.owenlejeune.tvtime.ui.components.ContentCard import com.owenlejeune.tvtime.ui.components.ContentCard
import com.owenlejeune.tvtime.ui.components.DetailHeader import com.owenlejeune.tvtime.ui.components.DetailHeader
import com.owenlejeune.tvtime.ui.components.ExpandableContentCard import com.owenlejeune.tvtime.ui.components.ExpandableContentCard
import com.owenlejeune.tvtime.ui.components.ExternalIdsArea import com.owenlejeune.tvtime.ui.components.ExternalIdsArea
import com.owenlejeune.tvtime.ui.components.TwoLineImageTextCard import com.owenlejeune.tvtime.ui.components.TwoLineImageTextCard
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@Composable @Composable
fun PersonDetailScreen( fun PersonDetailScreen(
appNavController: NavController, appNavController: NavController,
personId: Int? personId: Int
) { ) {
val mainViewModel = viewModel<MainViewModel>()
LaunchedEffect(Unit) {
mainViewModel.getById(personId, MediaViewType.PERSON)
mainViewModel.getExternalIds(personId, MediaViewType.PERSON)
}
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background)
systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background)
val person = remember { mutableStateOf<DetailPerson?>(null) } val peopleMap = remember { mainViewModel.peopleMap }
personId?.let { val person = peopleMap[personId]
if (person.value == null) {
fetchPerson(personId, person)
}
}
val decayAnimationSpec = rememberSplineBasedDecay<Float>() val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val topAppBarScrollState = rememberTopAppBarScrollState() val topAppBarScrollState = rememberTopAppBarScrollState()
@@ -74,7 +83,7 @@ fun PersonDetailScreen(
scrolledContainerColor = MaterialTheme.colorScheme.background, scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.primary titleContentColor = MaterialTheme.colorScheme.primary
), ),
title = { Text(text = person.value?.name ?: "") }, title = { Text(text = person?.name ?: "") },
navigationIcon = { navigationIcon = {
IconButton(onClick = { appNavController.popBackStack() }) { IconButton(onClick = { appNavController.popBackStack() }) {
Icon( Icon(
@@ -98,34 +107,23 @@ fun PersonDetailScreen(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
DetailHeader( DetailHeader(
posterUrl = TmdbUtils.getFullPersonImagePath(person.value?.profilePath), posterUrl = TmdbUtils.getFullPersonImagePath(person?.profilePath),
posterContentDescription = person.value?.profilePath posterContentDescription = person?.profilePath
) )
BiographyCard(person = person.value) BiographyCard(person = person)
val externalIds = remember { mutableStateOf<ExternalIds?>(null) } val externalIdsMap = remember { mainViewModel.peopleExternalIdsMap }
LaunchedEffect(Unit) { val externalIds = externalIdsMap[personId]
scope.launch { externalIds?.let {
val response = PeopleService().getExternalIds(personId!!)
if (response.isSuccessful) {
externalIds.value = response.body()!!
}
}
}
externalIds.value?.let {
ExternalIdsArea( ExternalIdsArea(
externalIds = it, externalIds = it,
modifier = Modifier.padding(start = 4.dp) modifier = Modifier.padding(start = 4.dp)
) )
} }
val credits = remember { mutableStateOf<PersonCreditsResponse?>(null) } val creditsMap = remember { mainViewModel.peopleCastMap }
personId?.let { val credits = creditsMap[personId]
if (credits.value == null) {
fetchCredits(personId, credits)
}
}
ContentCard( ContentCard(
title = stringResource(R.string.known_for_label) title = stringResource(R.string.known_for_label)
@@ -137,8 +135,8 @@ fun PersonDetailScreen(
.padding(12.dp), .padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(credits.value?.cast?.size ?: 0) { i -> items(credits?.size ?: 0) { i ->
val content = credits.value!!.cast[i] val content = credits!![i]
TwoLineImageTextCard( TwoLineImageTextCard(
title = content.name, title = content.name,
@@ -149,18 +147,18 @@ fun PersonDetailScreen(
.wrapContentHeight(), .wrapContentHeight(),
imageUrl = TmdbUtils.getFullPosterPath(content.posterPath), imageUrl = TmdbUtils.getFullPosterPath(content.posterPath),
onItemClicked = { onItemClicked = {
personId?.let { appNavController.navigate(
appNavController.navigate( AppNavItem.DetailView.withArgs(content.mediaType, content.id)
AppNavItem.DetailView.withArgs(content.mediaType, content.id) )
)
}
} }
) )
} }
} }
} }
val departments = credits.value?.crew?.map { it.department }?.toSet() ?: emptySet() val crewMap = remember { mainViewModel.peopleCrewMap }
val crewCredits = crewMap[personId]
val departments = crewCredits?.map { it.department }?.toSet() ?: emptySet()
if (departments.isNotEmpty()) { if (departments.isNotEmpty()) {
ContentCard(title = stringResource(R.string.also_known_for_label)) { ContentCard(title = stringResource(R.string.also_known_for_label)) {
Column( Column(
@@ -178,8 +176,7 @@ fun PersonDetailScreen(
.wrapContentHeight(), .wrapContentHeight(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
val jobsInDepartment = val jobsInDepartment = crewCredits!!.filter { it.department == department }
credits.value!!.crew.filter { it.department == department }
items(jobsInDepartment.size) { i -> items(jobsInDepartment.size) { i ->
val content = jobsInDepartment[i] val content = jobsInDepartment[i]
val title = if (content.mediaType == MediaViewType.MOVIE) { val title = if (content.mediaType == MediaViewType.MOVIE) {
@@ -195,11 +192,9 @@ fun PersonDetailScreen(
.wrapContentHeight(), .wrapContentHeight(),
imageUrl = TmdbUtils.getFullPosterPath(content.posterPath), imageUrl = TmdbUtils.getFullPosterPath(content.posterPath),
onItemClicked = { onItemClicked = {
personId?.let { appNavController.navigate(
appNavController.navigate( AppNavItem.DetailView.withArgs(content.mediaType, content.id)
AppNavItem.DetailView.withArgs(content.mediaType, content.id) )
)
}
} }
) )
} }
@@ -228,26 +223,4 @@ private fun BiographyCard(person: DetailPerson?) {
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
private fun fetchPerson(id: Int, person: MutableState<DetailPerson?>) {
CoroutineScope(Dispatchers.IO).launch {
val result = PeopleService().getPerson(id)
if (result.isSuccessful) {
withContext(Dispatchers.Main) {
person.value = result.body()
}
}
}
}
private fun fetchCredits(id: Int, credits: MutableState<PersonCreditsResponse?>) {
CoroutineScope(Dispatchers.IO).launch {
val result = PeopleService().getCredits(id)
if (result.isSuccessful) {
withContext(Dispatchers.Main) {
credits.value = result.body()
}
}
}
} }

View File

@@ -17,22 +17,27 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
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.MoviesService
import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchService 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.TvService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.extensions.listItems import com.owenlejeune.tvtime.extensions.listItems
import com.owenlejeune.tvtime.ui.components.MediaResultCard import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.ui.viewmodel.SearchViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.get
import com.owenlejeune.tvtime.extensions.lazyPagingItems
@Composable @Composable
fun SearchScreen( fun SearchScreen(
@@ -40,6 +45,8 @@ fun SearchScreen(
title: String, title: String,
mediaViewType: MediaViewType mediaViewType: MediaViewType
) { ) {
val searchViewModel = viewModel<SearchViewModel>()
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setStatusBarColor(color = MaterialTheme.colorScheme.background)
systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background) systemUiController.setNavigationBarColor(color = MaterialTheme.colorScheme.background)
@@ -52,6 +59,14 @@ fun SearchScreen(
val searchValue = rememberSaveable { mutableStateOf("") } val searchValue = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(searchValue.value) {
if (searchValue.value.isEmpty()) {
searchViewModel.resetResults()
} else {
searchViewModel.searchFor(searchValue.value, mediaViewType)
}
}
SmallTopAppBar( SmallTopAppBar(
title = { title = {
TextField( TextField(
@@ -100,68 +115,20 @@ fun SearchScreen(
) )
} }
if (searchValue.value.isNotEmpty()) { when (mediaViewType) {
when (mediaViewType) { MediaViewType.TV -> {
MediaViewType.TV -> { TvResultsView(appNavController = appNavController, searchViewModel = searchViewModel)
SearchResultListView(
showLoadingAnimation = showLoadingAnimation,
currentQuery = searchValue,
searchExecutor = { searchResults: MutableState<List<SearchResultTv>> ->
searchTv(searchValue.value, searchResults)
}
) { tv ->
TvSearchResultView(result = tv, appNavController = appNavController)
}
}
MediaViewType.MOVIE -> {
SearchResultListView(
showLoadingAnimation = showLoadingAnimation,
currentQuery = searchValue,
searchExecutor = { searchResults: MutableState<List<SearchResultMovie>> ->
searchMovies(searchValue.value, searchResults)
}
) { movie ->
MovieSearchResultView(result = movie, appNavController = appNavController)
}
}
MediaViewType.PERSON -> {
SearchResultListView(
showLoadingAnimation = showLoadingAnimation,
currentQuery = searchValue,
searchExecutor = { searchResults: MutableState<List<SearchResultPerson>> ->
searchPeople(searchValue.value, searchResults)
}
) { person ->
PeopleSearchResultView(result = person, appNavController = appNavController)
}
}
MediaViewType.MIXED -> {
SearchResultListView(
showLoadingAnimation = showLoadingAnimation,
currentQuery = searchValue,
searchExecutor = { searchResults: MutableState<List<SortableSearchResult>> ->
searchMulti(searchValue.value, searchResults)
},
) { item ->
when (item.mediaType) {
MediaViewType.MOVIE -> MovieSearchResultView(
appNavController = appNavController,
result = item as SearchResultMovie
)
MediaViewType.TV -> TvSearchResultView(
appNavController = appNavController,
result = item as SearchResultTv
)
MediaViewType.PERSON -> PeopleSearchResultView(
appNavController = appNavController,
result = item as SearchResultPerson
)
else -> {}
}
}
}
else -> {}
} }
MediaViewType.MOVIE -> {
MovieResultsView(appNavController = appNavController, searchViewModel = searchViewModel)
}
MediaViewType.PERSON -> {
PeopleResultsView(appNavController = appNavController, searchViewModel = searchViewModel)
}
MediaViewType.MIXED -> {
MultiResultsView(appNavController = appNavController, searchViewModel = searchViewModel)
}
else -> {}
} }
LaunchedEffect(key1 = "") { LaunchedEffect(key1 = "") {
@@ -171,41 +138,174 @@ fun SearchScreen(
} }
@Composable @Composable
private fun <T: SortableSearchResult> SearchResultListView( private fun MovieResultsView(
showLoadingAnimation: MutableState<Boolean>, appNavController: NavHostController,
currentQuery: MutableState<String>, searchViewModel: SearchViewModel
searchExecutor: (MutableState<List<T>>) -> Unit,
viewRenderer: @Composable (T) -> Unit
) { ) {
val searchResults = remember { mutableStateOf(emptyList<T>()) } val results = remember { searchViewModel.movieResults }
results.value?.let {
LaunchedEffect(key1 = currentQuery.value) { val pagingItems = it.collectAsLazyPagingItems()
showLoadingAnimation.value = true if (pagingItems.itemCount > 0) {
searchExecutor(searchResults) LazyColumn(
showLoadingAnimation.value = false modifier = Modifier.padding(all = 12.dp),
} verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (currentQuery.value.isNotEmpty() && searchResults.value.isEmpty()) { lazyPagingItems(pagingItems) { item ->
Column( item?.let {
modifier = Modifier.fillMaxSize() MovieSearchResultView(
) { appNavController = appNavController,
Spacer(modifier = Modifier.weight(1f)) result = item
Text( )
text = stringResource(R.string.no_search_results), }
color = MaterialTheme.colorScheme.onBackground, }
modifier = Modifier.align(Alignment.CenterHorizontally), }
fontSize = 18.sp } else {
) Column(
Spacer(modifier = Modifier.weight(1f)) modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.no_search_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.CenterHorizontally),
fontSize = 18.sp
)
Spacer(modifier = Modifier.weight(1f))
}
} }
} }
LazyColumn( }
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) @Composable
) { private fun TvResultsView(
val items = searchResults.value.sortedByDescending { it.popularity } appNavController: NavHostController,
listItems(items) { item -> searchViewModel: SearchViewModel
viewRenderer(item) ) {
val results = remember { searchViewModel.tvResults }
results.value?.let {
val pagingItems = it.collectAsLazyPagingItems()
if (pagingItems.itemCount > 0) {
LazyColumn(
modifier = Modifier.padding(all = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
lazyPagingItems(pagingItems) { item ->
item?.let {
TvSearchResultView(
appNavController = appNavController,
result = item
)
}
}
}
} else {
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.no_search_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.CenterHorizontally),
fontSize = 18.sp
)
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun PeopleResultsView(
appNavController: NavHostController,
searchViewModel: SearchViewModel
) {
val results = remember { searchViewModel.peopleResults }
results.value?.let {
val pagingItems = it.collectAsLazyPagingItems()
if (pagingItems.itemCount > 0) {
LazyColumn(
modifier = Modifier.padding(all = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
lazyPagingItems(pagingItems) { item ->
item?.let {
PeopleSearchResultView(
appNavController = appNavController,
result = item
)
}
}
}
} else {
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.no_search_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.CenterHorizontally),
fontSize = 18.sp
)
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun MultiResultsView(
appNavController: NavHostController,
searchViewModel: SearchViewModel
) {
val results = remember { searchViewModel.multiResults }
results.value?.let {
val pagingItems = it.collectAsLazyPagingItems()
if (pagingItems.itemCount > 0) {
LazyColumn(
modifier = Modifier.padding(all = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
lazyPagingItems(pagingItems) { item ->
item?.let {
when (item.mediaType) {
MediaViewType.MOVIE -> {
MovieSearchResultView(
appNavController = appNavController,
result = item as SearchResultMovie
)
}
MediaViewType.TV -> {
TvSearchResultView(
appNavController = appNavController,
result = item as SearchResultTv
)
}
MediaViewType.PERSON -> {
PeopleSearchResultView(
appNavController = appNavController,
result = item as SearchResultPerson
)
}
else ->{}
}
}
}
}
} else {
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.no_search_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.CenterHorizontally),
fontSize = 18.sp
)
Spacer(modifier = Modifier.weight(1f))
}
} }
} }
} }
@@ -233,10 +333,15 @@ private fun <T: SortableSearchResult> SearchResultItemView(
@Composable @Composable
private fun MovieSearchResultView( private fun MovieSearchResultView(
appNavController: NavHostController, appNavController: NavHostController,
result: SearchResultMovie result: SearchResultMovie,
service: MoviesService = get(MoviesService::class.java)
) { ) {
val cast = remember { mutableStateOf<List<CastMember>?>(null) } LaunchedEffect(Unit) {
getCast(result.id, MoviesService(), cast) service.getCastAndCrew(result.id)
}
val mainViewModel = viewModel<MainViewModel>()
val castMap = remember { mainViewModel.movieCast }
val cast = castMap[result.id]
SearchResultItemView( SearchResultItemView(
appNavController = appNavController, appNavController = appNavController,
@@ -247,7 +352,7 @@ private fun MovieSearchResultView(
additionalDetails = { additionalDetails = {
listOf( listOf(
TmdbUtils.releaseYearFromData(result.releaseDate), TmdbUtils.releaseYearFromData(result.releaseDate),
cast.value?.joinToString(separator = ", ") { it.name } ?: "" cast?.joinToString(separator = ", ") { it.name } ?: ""
) )
} }
) )
@@ -256,12 +361,16 @@ private fun MovieSearchResultView(
@Composable @Composable
private fun TvSearchResultView( private fun TvSearchResultView(
appNavController: NavHostController, appNavController: NavHostController,
result: SearchResultTv result: SearchResultTv,
service: TvService = get(TvService::class.java)
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
val cast = remember { mutableStateOf<List<CastMember>?>(null) } service.getCastAndCrew(result.id)
getCast(result.id, TvService(), cast) }
val mainViewModel = viewModel<MainViewModel>()
val castMap = remember { mainViewModel.tvCast }
val cast = castMap[result.id]
SearchResultItemView( SearchResultItemView(
appNavController = appNavController, appNavController = appNavController,
@@ -272,7 +381,7 @@ private fun TvSearchResultView(
additionalDetails = { additionalDetails = {
listOf( listOf(
"${TmdbUtils.releaseYearFromData(result.releaseDate)} ${context.getString(R.string.search_result_tv_series)}", "${TmdbUtils.releaseYearFromData(result.releaseDate)} ${context.getString(R.string.search_result_tv_series)}",
cast.value?.joinToString(separator = ", ") { it.name } ?: "" cast?.joinToString(separator = ", ") { it.name } ?: ""
) )
} }
) )
@@ -297,78 +406,4 @@ private fun PeopleSearchResultView(
) )
} }
) )
}
private fun searchMovies(
query: String,
searchResults: MutableState<List<SearchResultMovie>>
) {
CoroutineScope(Dispatchers.IO).launch {
val response = SearchService().searchMovies(query)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
searchResults.value = response.body()?.results ?: emptyList()
}
}
}
}
private fun searchTv(
query: String,
searchResults: MutableState<List<SearchResultTv>>
) {
CoroutineScope(Dispatchers.IO).launch {
val response = SearchService().searchTv(query)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
searchResults.value = response.body()?.results ?: emptyList()
}
}
}
}
private fun searchPeople(
query: String,
searchResults: MutableState<List<SearchResultPerson>>
) {
CoroutineScope(Dispatchers.IO).launch {
val response = SearchService().searchPeople(query)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
searchResults.value = response.body()?.results ?: emptyList()
}
}
}
}
private fun searchMulti(
query: String,
searchResults: MutableState<List<SortableSearchResult>>
) {
CoroutineScope(Dispatchers.IO).launch {
val response = SearchService().searchMulti(query)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
searchResults.value = response.body()?.results ?: emptyList()
}
}
}
}
private fun getCast(
id: Int,
detailService: DetailService,
cast: MutableState<List<CastMember>?>
) {
CoroutineScope(Dispatchers.IO).launch {
val response = detailService.getCastAndCrew(id)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
cast.value = response.body()?.cast?.let {
val end = minOf(2, it.size)
it.subList(0, end)
}
}
}
}
} }

View File

@@ -0,0 +1,46 @@
package com.owenlejeune.tvtime.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MarkAsFavoriteBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchlistBody
import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Service
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsBody
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsItem
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListUpdateBody
import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.types.MediaViewType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class AccountViewModel: ViewModel(), KoinComponent {
private val listService: ListV4Service by inject()
private val accountService: AccountService by inject()
val listMap = listService.listMap
suspend fun getList(listId: Int) {
listService.getList(listId = listId)
}
suspend fun deleteListItem(listId: Int, itemId: Int, itemType: MediaViewType) {
val removeItemBody = DeleteListItemsItem(itemId, itemType)
listService.deleteListItems(listId, DeleteListItemsBody(listOf(removeItemBody)))
}
suspend fun updateList(listId: Int, body: ListUpdateBody) {
listService.updateList(listId, body)
}
suspend fun addToFavourites(type: MediaViewType, itemId: Int, favourited: Boolean) {
val accountId = SessionManager.currentSession.value?.accountDetails?.value?.id ?: throw Exception("Session must not be null")
accountService.markAsFavorite(accountId, MarkAsFavoriteBody(type, itemId, favourited))
}
suspend fun addToWatchlist(type: MediaViewType, itemId: Int, watchlisted: Boolean) {
val accountId = SessionManager.currentSession.value?.accountDetails?.value?.id ?: throw Exception("Session must not be null")
accountService.addToWatchlist(accountId, WatchlistBody(type, itemId, watchlisted))
}
}

View File

@@ -1,125 +1,28 @@
package com.owenlejeune.tvtime.ui.viewmodel package com.owenlejeune.tvtime.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationApi import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationService
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import retrofit2.Response
class ConfigurationViewModel: ViewModel(), KoinComponent { class ConfigurationViewModel: ViewModel(), KoinComponent {
companion object { private val service: ConfigurationService by inject()
private const val TAG = "ConfigurationViewModel"
}
private object Backer { val detailsConfiguration = service.detailsConfiguration
val detailsConfiguration = mutableStateOf(ConfigurationDetails.Empty) val countriesConfiguration = service.countriesConfiguration
val countriesConfiguration = mutableStateListOf<ConfigurationCountry>() val jobsConfiguration = service.jobsConfiguration
val jobsConfiguration = mutableStateListOf<ConfigurationJob>() val languagesConfiguration = service.languagesConfiguration
val languagesConfiguration = mutableStateListOf<ConfigurationLanguage>() val primaryTranslationsConfiguration = service.primaryTranslationsConfiguration
val primaryTranslationsConfiguration = mutableStateListOf<String>() val timezonesConfiguration = service.timezonesConfiguration
val timezonesConfiguration = mutableStateListOf<ConfigurationTimezone>()
}
private val service: ConfigurationApi by inject()
val detailsConfiguration = Backer.detailsConfiguration
val countriesConfiguration = Backer.countriesConfiguration
val jobsConfiguration = Backer.jobsConfiguration
val languagesConfiguration = Backer.languagesConfiguration
val primaryTranslationsConfiguration = Backer.primaryTranslationsConfiguration
val timezonesConfiguration = Backer.timezonesConfiguration
suspend fun getConfigurations() { suspend fun getConfigurations() {
getDetailsConfiguration() service.getDetailsConfiguration()
getCountriesConfiguration() service.getCountriesConfiguration()
getJobsConfiguration() service.getJobsConfiguration()
getLanguagesConfiguration() service.getLanguagesConfiguration()
getPrimaryTranslationsConfiguration() service.getPrimaryTranslationsConfiguration()
getTimezonesConfiguration() service.getTimezonesConfiguration()
}
suspend fun getDetailsConfiguration() {
getConfiguration(
{ service.getDetailsConfiguration() },
{ detailsConfiguration.value = it }
)
}
suspend fun getCountriesConfiguration() {
getConfiguration(
{ service.getCountriesConfiguration() },
{
countriesConfiguration.clear()
countriesConfiguration.addAll(it)
}
)
}
suspend fun getJobsConfiguration() {
getConfiguration(
{ service.getJobsConfiguration() },
{
jobsConfiguration.clear()
jobsConfiguration.addAll(it)
}
)
}
suspend fun getLanguagesConfiguration() {
getConfiguration(
{ service.getLanguagesConfiguration() },
{
languagesConfiguration.clear()
languagesConfiguration.addAll(it)
}
)
}
suspend fun getPrimaryTranslationsConfiguration() {
getConfiguration(
{ service.getPrimaryTranslationsConfiguration() },
{
primaryTranslationsConfiguration.clear()
primaryTranslationsConfiguration.addAll(it)
}
)
}
suspend fun getTimezonesConfiguration() {
getConfiguration(
{ service.getTimezonesConfiguration() },
{
timezonesConfiguration.clear()
timezonesConfiguration.addAll(it)
}
)
}
private suspend fun <T> getConfiguration(
fetcher: suspend () -> Response<T>,
bodyHandler: (T) -> Unit
) {
val response = fetcher()
if (response.isSuccessful) {
response.body()?.let {
Log.d(TAG, "Successfully got configuration: $it")
bodyHandler(it)
}
} else {
Log.e(TAG, "Issue getting configuration")
}
} }
} }

View File

@@ -0,0 +1,243 @@
package com.owenlejeune.tvtime.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
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.SimilarMoviesSource
import com.owenlejeune.tvtime.api.tmdb.api.v3.SimilarTvSource
import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService
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.DetailedItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds
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.RatingBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Review
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.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class MainViewModel: ViewModel(), KoinComponent {
private val movieService: MoviesService by inject()
private val tvService: TvService by inject()
private val peopleService: PeopleService by inject()
val detailMovies = movieService.detailMovies
val movieImages = movieService.images
val movieCast = movieService.cast
val movieCrew = movieService.crew
val movieVideos = movieService.videos
val movieReviews = movieService.reviews
val movieKeywords = movieService.keywords
val movieWatchProviders = movieService.watchProviders
val movieExternalIds = movieService.externalIds
val movieReleaseDates = movieService.releaseDates
val similarMovies = movieService.similar
val movieAccountStates = movieService.accountStates
val detailedTv = tvService.detailTv
val tvImages = tvService.images
val tvCast = tvService.cast
val tvCrew = tvService.crew
val tvVideos = tvService.videos
val tvReviews = tvService.reviews
val tvKeywords = tvService.keywords
val tvWatchProviders = tvService.watchProviders
val tvExternalIds = tvService.externalIds
val tvContentRatings = tvService.contentRatings
val tvSeasons = tvService.seasons
val similarTv = tvService.similar
val tvAccountStates = tvService.accountStates
val peopleMap = peopleService.peopleMap
val peopleCastMap = peopleService.castMap
val peopleCrewMap = peopleService.crewMap
val peopleImagesMap = peopleService.imagesMap
val peopleExternalIdsMap = peopleService.externalIdsMap
private fun <T> providesForType(type: MediaViewType, movies: () -> T, tv: () -> T): T {
return when (type) {
MediaViewType.MOVIE -> movies()
MediaViewType.TV -> tv()
else -> throw ViewableMediaTypeException(type) // shouldn't happen
}
}
fun produceDetailsFor(type: MediaViewType): Map<Int, DetailedItem> {
return providesForType(type, { detailMovies }, { detailedTv })
}
fun produceImagesFor(type: MediaViewType): Map<Int, ImageCollection> {
return providesForType(type, { movieImages }, { tvImages })
}
fun produceExternalIdsFor(type: MediaViewType): Map<Int, ExternalIds> {
return providesForType(type, { movieExternalIds }, { tvExternalIds })
}
fun produceKeywordsFor(type: MediaViewType): Map<Int, List<Keyword>> {
return providesForType(type, { movieKeywords }, { tvKeywords })
}
fun produceCastFor(type: MediaViewType): Map<Int, List<CastMember>> {
return providesForType(type, { movieCast }, { tvCast })
}
fun produceCrewFor(type: MediaViewType): Map<Int, List<CrewMember>> {
return providesForType(type, { movieCrew }, { tvCrew })
}
fun produceVideosFor(type: MediaViewType): Map<Int, List<Video>> {
return providesForType(type, { movieVideos }, { tvVideos })
}
fun produceWatchProvidersFor(type: MediaViewType): Map<Int, WatchProviders> {
return providesForType(type, { movieWatchProviders }, { tvWatchProviders })
}
fun produceReviewsFor(type: MediaViewType): Map<Int, List<Review>> {
return providesForType(type, { movieReviews }, { tvReviews })
}
fun produceSimilarContentFor(type: MediaViewType): Map<Int, Flow<PagingData<TmdbItem>>> {
return providesForType(type, { similarMovies }, { similarTv })
}
fun produceAccountStatesFor(type: MediaViewType): Map<Int, AccountStates> {
return providesForType(type, { movieAccountStates }, { tvAccountStates} )
}
suspend fun getById(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getById(id)
MediaViewType.TV -> tvService.getById(id)
MediaViewType.PERSON -> peopleService.getPerson(id)
else -> {}
}
}
suspend fun getImages(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getImages(id)
MediaViewType.TV -> tvService.getImages(id)
MediaViewType.PERSON -> peopleService.getImages(id)
else -> {}
}
}
suspend fun getCastAndCrew(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getCastAndCrew(id)
MediaViewType.TV -> tvService.getCastAndCrew(id)
MediaViewType.PERSON -> tvService.getCastAndCrew(id)
else -> {}
}
}
suspend fun getVideos(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getVideos(id)
MediaViewType.TV -> tvService.getVideos(id)
else -> {}
}
}
suspend fun getReviews(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getReviews(id)
MediaViewType.TV -> tvService.getReviews(id)
else -> {}
}
}
suspend fun getKeywords(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getKeywords(id)
MediaViewType.TV -> tvService.getKeywords(id)
else -> {}
}
}
suspend fun getWatchProviders(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getWatchProviders(id)
MediaViewType.TV -> tvService.getWatchProviders(id)
else -> {}
}
}
suspend fun getExternalIds(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getExternalIds(id)
MediaViewType.TV -> tvService.getExternalIds(id)
MediaViewType.PERSON -> peopleService.getExternalIds(id)
else -> {}
}
}
suspend fun getAccountStates(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.getAccountStates(id)
MediaViewType.TV -> tvService.getAccountStates(id)
else -> {}
}
}
suspend fun postRating(id: Int, rating: Float, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.postRating(id, RatingBody(rating))
MediaViewType.TV -> tvService.postRating(id, RatingBody(rating))
else -> {}
}
}
suspend fun deleteRating(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> movieService.deleteRating(id)
MediaViewType.TV -> tvService.deleteRating(id)
else -> {}
}
}
fun getSimilar(id: Int, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> {
similarMovies[id] = Pager(PagingConfig(pageSize = 1)) {
SimilarMoviesSource(id)
}.flow.cachedIn(viewModelScope)
}
MediaViewType.TV -> {
similarTv[id] = Pager(PagingConfig(pageSize = 1)) {
SimilarTvSource(id)
}.flow.cachedIn(viewModelScope)
}
else -> {}
}
}
suspend fun getReleaseDates(id: Int) {
movieService.getReleaseDates(id)
}
suspend fun getContentRatings(id: Int) {
tvService.getContentRatings(id)
}
suspend fun getSeason(seriesId: Int, seasonId: Int) {
tvService.getSeason(seriesId, seasonId)
}
}

View File

@@ -13,20 +13,22 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePagingSource
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.ui.navigation.MediaFetchFun import com.owenlejeune.tvtime.ui.navigation.MediaFetchFun
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.get
sealed class MediaTabViewModel(service: HomePageService, mediaFetchFun: MediaFetchFun, tag: String): ViewModel() { sealed class MediaTabViewModel(service: HomePageService, mediaFetchFun: MediaFetchFun, tag: String): ViewModel(), KoinComponent {
val mediaItems: Flow<PagingData<TmdbItem>> = Pager(PagingConfig(pageSize = ViewModelConstants.PAGING_SIZE)) { val mediaItems: Flow<PagingData<TmdbItem>> = Pager(PagingConfig(pageSize = ViewModelConstants.PAGING_SIZE)) {
HomePagePagingSource(service = service, mediaFetch = mediaFetchFun, tag = tag) HomePagePagingSource(service = service, mediaFetch = mediaFetchFun, tag = tag)
}.flow.cachedIn(viewModelScope) }.flow.cachedIn(viewModelScope)
object PopularMoviesVM: MediaTabViewModel(MoviesService(), { s, p -> s.getPopular(p) }, PopularMoviesVM::class.java.simpleName) object PopularMoviesVM: MediaTabViewModel(get(MoviesService::class.java), { s, p -> s.getPopular(p) }, PopularMoviesVM::class.java.simpleName)
object TopRatedMoviesVM: MediaTabViewModel(MoviesService(), { s, p -> s.getTopRated(p) }, TopRatedMoviesVM::class.java.simpleName) object TopRatedMoviesVM: MediaTabViewModel(get(MoviesService::class.java), { s, p -> s.getTopRated(p) }, TopRatedMoviesVM::class.java.simpleName)
object NowPlayingMoviesVM: MediaTabViewModel(MoviesService(), { s, p -> s.getNowPlaying(p) }, NowPlayingMoviesVM::class.java.simpleName) object NowPlayingMoviesVM: MediaTabViewModel(get(MoviesService::class.java), { s, p -> s.getNowPlaying(p) }, NowPlayingMoviesVM::class.java.simpleName)
object UpcomingMoviesVM: MediaTabViewModel(MoviesService(), { s, p -> s.getUpcoming(p) }, UpcomingMoviesVM::class.java.simpleName) object UpcomingMoviesVM: MediaTabViewModel(get(MoviesService::class.java), { s, p -> s.getUpcoming(p) }, UpcomingMoviesVM::class.java.simpleName)
object PopularTvVM: MediaTabViewModel(TvService(), { s, p -> s.getPopular(p) }, PopularTvVM::class.java.simpleName) object PopularTvVM: MediaTabViewModel(get(TvService::class.java), { s, p -> s.getPopular(p) }, PopularTvVM::class.java.simpleName)
object TopRatedTvVM: MediaTabViewModel(TvService(), { s, p -> s.getTopRated(p) }, TopRatedTvVM::class.java.simpleName) object TopRatedTvVM: MediaTabViewModel(get(TvService::class.java), { s, p -> s.getTopRated(p) }, TopRatedTvVM::class.java.simpleName)
object AiringTodayTvVM: MediaTabViewModel(TvService(), { s, p -> s.getNowPlaying(p) }, AiringTodayTvVM::class.java.simpleName) object AiringTodayTvVM: MediaTabViewModel(get(TvService::class.java), { s, p -> s.getNowPlaying(p) }, AiringTodayTvVM::class.java.simpleName)
object OnTheAirTvVM: MediaTabViewModel(TvService(), { s, p -> s.getUpcoming(p) }, OnTheAirTvVM::class.java.simpleName) object OnTheAirTvVM: MediaTabViewModel(get(TvService::class.java), { s, p -> s.getUpcoming(p) }, OnTheAirTvVM::class.java.simpleName)
} }

View File

@@ -0,0 +1,70 @@
package com.owenlejeune.tvtime.ui.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchPagingSource
import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchResultProvider
import com.owenlejeune.tvtime.api.tmdb.api.v3.SearchService
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 com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SearchViewModel: ViewModel(), KoinComponent {
private val service: SearchService by inject()
val movieResults = service.movieResults
val tvResults = service.tvResults
val peopleResults = service.peopleResults
val multiResults = service.multiResults
fun resetResults() {
movieResults.value = null
tvResults.value = null
peopleResults.value = null
multiResults.value = null
}
fun searchFor(query: String, type: MediaViewType) {
when (type) {
MediaViewType.MOVIE -> searchForMovies(query)
MediaViewType.TV -> searchForTv(query)
MediaViewType.PERSON -> searchForPeople(query)
MediaViewType.MIXED -> searchMulti(query)
else -> {}
}
}
fun searchForMovies(query: String) {
movieResults.value = createPagingSource(viewModelScope) { service.searchMovies(query, it) }
}
fun searchForTv(query: String) {
tvResults.value = createPagingSource(viewModelScope) { service.searchTv(query, it) }
}
fun searchForPeople(query: String) {
peopleResults.value = createPagingSource(viewModelScope) { service.searchPeople(query, it) }
}
fun searchMulti(query: String) {
multiResults.value = createPagingSource(viewModelScope) { service.searchMulti(query, it) }
}
private fun <T: Searchable> createPagingSource(viewModelScope: CoroutineScope, provideResults: SearchResultProvider<T>): Flow<PagingData<T>> {
return Pager(PagingConfig(pageSize = 1)) { SearchPagingSource(provideResults) }.flow.cachedIn(viewModelScope)
}
}

View File

@@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService 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.model.* import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service 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.AuthenticationV4Service
@@ -30,12 +31,11 @@ import java.nio.charset.StandardCharsets
object SessionManager: KoinComponent { object SessionManager: KoinComponent {
private val preferences: AppPreferences by inject() private val preferences: AppPreferences by inject()
private val authenticationService: AuthenticationService by inject()
private val authenticationV4Service: AuthenticationV4Service by inject()
val currentSession = mutableStateOf<Session?>(null) val currentSession = mutableStateOf<Session?>(null)
private val authenticationService by lazy { TmdbClient().createAuthenticationService() }
private val authenticationV4Service by lazy { TmdbClient().createV4AuthenticationService() }
class AuthorizedSessionValues( class AuthorizedSessionValues(
@SerializedName("session_id") val sessionId: String, @SerializedName("session_id") val sessionId: String,
@SerializedName("access_token") val accessToken: String, @SerializedName("access_token") val accessToken: String,
@@ -132,50 +132,15 @@ object SessionManager: KoinComponent {
} }
abstract class Session(val sessionId: String, val isAuthorized: Boolean, val accessToken: String = "", val accountId: String = "") { abstract class Session(val sessionId: String, val isAuthorized: Boolean, val accessToken: String = "", val accountId: String = "") {
// protected open var _ratedMovies: List<RatedMovie> = emptyList()
// val ratedMovies: List<RatedMovie>
// get() = _ratedMovies
val ratedMovies = mutableStateListOf<RatedMovie>() val ratedMovies = mutableStateListOf<RatedMovie>()
// protected open var _ratedTvShows: List<RatedTv> = emptyList()
// val ratedTvShows: List<RatedTv>
// get() = _ratedTvShows
val ratedTvShows = mutableStateListOf<RatedTv>() val ratedTvShows = mutableStateListOf<RatedTv>()
// protected open var _ratedTvEpisodes: List<RatedEpisode> = emptyList()
// val ratedTvEpisodes: List<RatedEpisode>
// get() = _ratedTvEpisodes
val ratedTvEpisodes = mutableStateListOf<RatedEpisode>() val ratedTvEpisodes = mutableStateListOf<RatedEpisode>()
// protected open var _accountDetails: AccountDetails? = null
// val accountDetails: AccountDetails?
// get() = _accountDetails
val accountDetails = mutableStateOf<AccountDetails?>(null) val accountDetails = mutableStateOf<AccountDetails?>(null)
// protected open var _accountLists: List<V4AccountList> = emptyList()
// val accountLists: List<V4AccountList>
// get() = _accountLists
val accountLists = mutableStateListOf<AccountList>() val accountLists = mutableStateListOf<AccountList>()
// protected open var _favoriteMovies: List<FavoriteMovie> = emptyList()
// val favoriteMovies: List<FavoriteMovie>
// get() = _favoriteMovies
val favoriteMovies = mutableStateListOf<FavoriteMovie>() val favoriteMovies = mutableStateListOf<FavoriteMovie>()
// protected open var _favoriteTvShows: List<FavoriteTvSeries> = emptyList()
// val favoriteTvShows: List<FavoriteTvSeries>
// get() = _favoriteTvShows
val favoriteTvShows = mutableStateListOf<FavoriteTvSeries>() val favoriteTvShows = mutableStateListOf<FavoriteTvSeries>()
// protected open var _movieWatchlist: List<WatchlistMovie> = emptyList()
// val movieWatchlist: List<WatchlistMovie>
// get() = _movieWatchlist
val movieWatchlist = mutableStateListOf<WatchlistMovie>() val movieWatchlist = mutableStateListOf<WatchlistMovie>()
// protected open var _tvWatchlist: List<WatchlistTvSeries> = emptyList()
// val tvWatchlist: List<WatchlistTvSeries>
// get() = _tvWatchlist
val tvWatchlist = mutableStateListOf<WatchlistTvSeries>() val tvWatchlist = mutableStateListOf<WatchlistTvSeries>()
fun hasRatedMovie(id: Int): Boolean { fun hasRatedMovie(id: Int): Boolean {

View File

@@ -145,14 +145,14 @@ object TmdbUtils {
} }
} }
fun getMovieRating(releases: MovieReleaseResults?): String { fun getMovieRating(releases: List<MovieReleaseResults.ReleaseDateResult>?): String {
if (releases == null) { if (releases == null) {
return "" return ""
} }
val currentRegion = Locale.current.language val currentRegion = Locale.current.language
val certifications = HashMap<String, String>() val certifications = HashMap<String, String>()
releases.releaseDates.forEach { releaseDateResult -> releases.forEach { releaseDateResult ->
if (releaseDateResult.region == currentRegion || releaseDateResult.region == DEF_REGION) { if (releaseDateResult.region == currentRegion || releaseDateResult.region == DEF_REGION) {
val cert = releaseDateResult.releaseDates.firstOrNull { it.certification.isNotEmpty() } val cert = releaseDateResult.releaseDates.firstOrNull { it.certification.isNotEmpty() }
if (cert != null) { if (cert != null) {
@@ -166,14 +166,14 @@ object TmdbUtils {
return "" return ""
} }
fun getTvRating(contentRatings: TvContentRatings?): String { fun getTvRating(contentRatings: List<TvContentRatings.TvContentRating>?): String {
if (contentRatings == null) { if (contentRatings == null) {
return "" return ""
} }
val currentRegion = Locale.current.language val currentRegion = Locale.current.language
val certifications = HashMap<String, String>() val certifications = HashMap<String, String>()
contentRatings.results.forEach { contentRating -> contentRatings.forEach { contentRating ->
if (contentRating.language == currentRegion || contentRating.language == DEF_REGION) { if (contentRating.language == currentRegion || contentRating.language == DEF_REGION) {
certifications[contentRating.language] = contentRating.rating certifications[contentRating.language] = contentRating.rating
} }

View File

@@ -20,4 +20,6 @@ enum class MediaViewType {
return values()[oridinal] return values()[oridinal]
} }
} }
} }
class ViewableMediaTypeException(type: MediaViewType): IllegalArgumentException("Media type given: ${type}, \n expected one of MediaViewType.MOVIE, MediaViewType.TV")