add watch provider info

This commit is contained in:
Owen LeJeune
2023-06-13 20:47:20 -04:00
parent 906c586da8
commit 76083761a9
26 changed files with 344 additions and 398 deletions

View File

@@ -114,8 +114,9 @@ dependencies {
implementation "com.localebro:okhttpprofiler:$profiler"
// koin
def koin = "3.1.4"
def koin = "3.3.0"
implementation "io.insert-koin:koin-android:$koin"
implementation "io.insert-koin:koin-androidx-compose:$koin"
// coil
def coil = "2.2.2"

View File

@@ -6,6 +6,7 @@ import com.kieronquinn.monetcompat.core.MonetCompat
import com.owenlejeune.tvtime.di.modules.appModule
import com.owenlejeune.tvtime.di.modules.networkModule
import com.owenlejeune.tvtime.di.modules.preferencesModule
import com.owenlejeune.tvtime.di.modules.viewModelModule
import com.owenlejeune.tvtime.preferences.AppPreferences
import dev.kdrag0n.monet.factory.ColorSchemeFactory
import org.koin.android.ext.android.inject
@@ -30,7 +31,8 @@ class TvTimeApplication: Application() {
modules(
networkModule,
preferencesModule,
appModule
appModule,
viewModelModule
)
}

View File

@@ -23,4 +23,6 @@ interface DetailService {
suspend fun getKeywords(id: Int): Response<KeywordsResponse>
suspend fun getWatchProviders(id: Int): Response<WatchProviderResponse>
}

View File

@@ -55,4 +55,7 @@ interface MoviesApi {
@Query("session_id") sessionId: String
): Response<StatusResponse>
@GET("movie/{id}/watch/providers")
suspend fun getWatchProviders(@Path("id") id: Int): Response<WatchProviderResponse>
}

View File

@@ -11,6 +11,7 @@ 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.StatusResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse
import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.KoinComponent
import retrofit2.Response
@@ -77,4 +78,8 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
return movieService.getKeywords(id)
}
override suspend fun getWatchProviders(id: Int): Response<WatchProviderResponse> {
return movieService.getWatchProviders(id)
}
}

View File

@@ -58,4 +58,7 @@ interface TvApi {
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
@GET("tv/{id}/watch/providers")
suspend fun getWatchProviders(@Path("id") seriesId: Int): Response<WatchProviderResponse>
}

View File

@@ -12,6 +12,7 @@ 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.TvContentRatings
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse
import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.KoinComponent
import retrofit2.Response
@@ -78,6 +79,10 @@ class TvService: KoinComponent, DetailService, HomePageService {
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)
}

View File

@@ -22,7 +22,7 @@ class DetailedMovie(
@SerializedName("adult") val isAdult: Boolean,
@SerializedName("budget") val budget: Int,
@SerializedName("release_date") val releaseDate: String,
@SerializedName("revenue") val revenue: Int,
@SerializedName("revenue") val revenue: Long,
@SerializedName("runtime") val runtime: Int?,
@SerializedName("imdb_id") val imdbId: String?,
@SerializedName("belongs_to_collection") val collection: Collection?

View File

@@ -0,0 +1,32 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
data class WatchProviderResponse(
@SerializedName("id")
val id: Int,
@SerializedName("results")
val results: Map<String, WatchProviders>
)
data class WatchProviders(
@SerializedName("link")
val link: String,
@SerializedName("flatrate")
val flaterate: List<WatchProviderDetails>?,
@SerializedName("rent")
val rent: List<WatchProviderDetails>?,
@SerializedName("buy")
val buy: List<WatchProviderDetails>?
)
data class WatchProviderDetails(
@SerializedName("logo_path")
val logoPath: String,
@SerializedName("provider_id")
val providerId: Int,
@SerializedName("provider_name")
val providerName: String,
@SerializedName("display_priority")
val displayPriority: Int
)

View File

@@ -18,7 +18,7 @@ class MediaList(
@SerializedName("average_rating") val averageRating: Float,
@SerializedName("runtime") val runtime: Int,
@SerializedName("name") val name: String,
@SerializedName("revenue") val revenue: Int,
@SerializedName("revenue") val revenue: Long,
@SerializedName("sort_by") val sortBy: SortOrder
)

View File

@@ -14,7 +14,10 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service
import com.owenlejeune.tvtime.api.tmdb.api.v4.deserializer.ListItemDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.SettingsViewModel
import com.owenlejeune.tvtime.utils.ResourceUtils
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val networkModule = module {
@@ -61,4 +64,9 @@ val preferencesModule = module {
val appModule = module {
factory { ResourceUtils(get()) }
}
val viewModelModule = module {
viewModel { ConfigurationViewModel() }
viewModel { SettingsViewModel() }
}

View File

@@ -0,0 +1,9 @@
package com.owenlejeune.tvtime.extensions
fun <T> List<T>.lastOr(provider: () -> T): T {
return if (isNotEmpty()) {
last()
} else {
provider()
}
}

View File

@@ -62,7 +62,7 @@ fun ContentCard(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
modifier = Modifier.padding(start = 16.dp, top = 12.dp),
color = textColor
)
}
@@ -119,41 +119,6 @@ fun ExpandableContentCard(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LazyListContentCard(
modifier: Modifier = Modifier,
header: @Composable (() -> Unit)? = null,
footer: @Composable (() -> Unit)? = null,
backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
content: LazyListScope.() -> Unit
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
header?.invoke()
val listState = rememberLazyListState()
LazyColumn(
content = content,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = listState
)
footer?.invoke()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListContentCard(
@@ -226,258 +191,4 @@ fun TwoLineImageTextCard(
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableActionCard(
modifier: Modifier = Modifier,
leftSwipeCard: (@Composable () -> Unit)? = null,
rightSwipeCard: (@Composable () -> Unit)? = null,
leftSwiped: () -> Unit = {},
rightSwiped: () -> Unit = {},
animationSpec: AnimationSpec<Float> = tween(250),
velocityThreshold: Dp = 125.dp,
// shape: Shape = RectangleShape,
mainContent: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val thresholds = { _: SwipeCardState, _: SwipeCardState ->
FractionalThreshold(0.6f)
}
Box(
modifier = modifier//.clip(shape)
) {
val swipeableState = rememberSwipeableState(
initialValue = SwipeCardState.DEFAULT,
animationSpec = animationSpec
)
val swipeLeftCardVisible = remember { mutableStateOf(false) }
val swipeEnabled = remember { mutableStateOf(true) }
val maxWidthInPx = with(LocalDensity.current) {
LocalConfiguration.current.screenWidthDp.dp.toPx()
}
val anchors = hashMapOf(0f to SwipeCardState.DEFAULT)
leftSwipeCard?.let { anchors[-maxWidthInPx] = SwipeCardState.LEFT }
rightSwipeCard?.let { anchors[maxWidthInPx] = SwipeCardState.RIGHT }
Surface(
color = Color.Transparent,
content = if (swipeLeftCardVisible.value) {
leftSwipeCard
} else {
rightSwipeCard
} ?: {}
)
Surface(
color = Color.Transparent,
modifier = Modifier
.fillMaxWidth()
.offset {
var offset = swipeableState.offset.value.roundToInt()
if (offset < 0 && leftSwipeCard == null) offset = 0
if (offset > 0 && rightSwipeCard == null) offset = 0
IntOffset(offset, 0)
}
.swipeable(
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
enabled = swipeEnabled.value,
thresholds = thresholds,
velocityThreshold = velocityThreshold
)
) {
val resetStateToDefault = {
coroutineScope.launch {
swipeEnabled.value = false
swipeableState.animateTo(SwipeCardState.DEFAULT)
swipeEnabled.value = true
}
}
when {
swipeableState.currentValue == SwipeCardState.LEFT && !swipeableState.isAnimationRunning -> {
leftSwiped()
LaunchedEffect(Unit) {
resetStateToDefault()
}
}
swipeableState.currentValue == SwipeCardState.RIGHT && !swipeableState.isAnimationRunning -> {
rightSwiped()
LaunchedEffect(Unit) {
resetStateToDefault()
}
}
}
swipeLeftCardVisible.value = swipeableState.offset.value <= 0
mainContent()
}
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun DraggableCard(
modifier: Modifier = Modifier,
cardOffset: Float,
isRevealed: Boolean,
cardElevation: CardElevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
shape: Shape = RectangleShape,
onExpand: () -> Unit = {},
onCollapse: () -> Unit = {},
backgroundContent: @Composable () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val animationDuration = 500
val velicityThreshold = 125.dp
val thresholds: (from: SwipeCardState, to: SwipeCardState) -> ThresholdConfig = { _, _ ->
FractionalThreshold(0.6f)
}
val swipeableState = rememberSwipeableState(
initialValue = SwipeCardState.DEFAULT,
animationSpec = tween(animationDuration)
)
val maxWidthPx = with(LocalDensity.current) {
LocalConfiguration.current.screenWidthDp.dp.toPx()
}
val anchors = hashMapOf(0f to SwipeCardState.DEFAULT)
anchors[-maxWidthPx] = SwipeCardState.LEFT
val swipeEnabled = remember { mutableStateOf(true) }
Box (
modifier = Modifier.clip(shape = shape)
) {
backgroundContent()
Card(
shape = shape,
elevation = cardElevation,
modifier = modifier
.background(color = backgroundColor)
.offset {
var offset = swipeableState.offset.value.roundToInt()
if (offset < 0) offset = 0
IntOffset(offset, 0)
}
.swipeable(
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
enabled = swipeEnabled.value,
thresholds = thresholds,
velocityThreshold = velicityThreshold
)
) {
if (swipeableState.currentValue == SwipeCardState.LEFT && !swipeableState.isAnimationRunning) {
onExpand()
LaunchedEffect(key1 = Unit) {
coroutineScope.launch {
swipeEnabled.value = false
swipeableState.animateTo(SwipeCardState.DEFAULT)
swipeEnabled.value = true
}
}
}
content()
}
// DraggableCardInternal(
// modifier = modifier,
// cardOffset = cardOffset,
// isRevealed = isRevealed,
// cardElevation = cardElevation,
// backgroundColor = backgroundColor,
// shape = shape,
// onExpand = onExpand,
// onCollapse = onCollapse,
// content = content
// )
}
}
private enum class SwipeCardState {
DEFAULT,
LEFT,
RIGHT
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@SuppressLint("UnusedTransitionTargetStateParameter")
@Composable
fun DraggableCardInternal(
modifier: Modifier = Modifier,
cardOffset: Float,
isRevealed: Boolean,
cardElevation: CardElevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
shape: Shape = RectangleShape,
onExpand: () -> Unit = {},
onCollapse: () -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
val animationDuration = 500
val swipeableState = rememberSwipeableState(
initialValue = SwipeCardState.DEFAULT,
animationSpec = tween(animationDuration)
)
val maxWidthPx = with(LocalDensity.current) {
LocalConfiguration.current.screenWidthDp.dp.toPx()
}
val anchors = hashMapOf(0f to SwipeCardState.DEFAULT)
anchors[-maxWidthPx] = SwipeCardState.LEFT
Card(
modifier = modifier
.background(color = backgroundColor),
shape = shape,
elevation = cardElevation
){}
// val animationDuration = 500
// val minDragAmount = 5
//
// val transitionState = remember {
// MutableTransitionState(isRevealed).apply {
// targetState = !isRevealed
// }
// }
// val transition = updateTransition(targetState = transitionState, "cardTransition")
// val offsetTransition by transition.animateFloat(
// label = "cardOffsetTransition",
// transitionSpec = { tween(durationMillis = animationDuration) },
// targetValueByState = { if (isRevealed) cardOffset else 0f },
// )
//
// Card(
// modifier = modifier
// .background(color = backgroundColor)
// .offset { IntOffset(offsetTransition.roundToInt(), 0) }
// .pointerInput(Unit) {
// detectHorizontalDragGestures { _, dragAmount ->
// when {
// dragAmount >= minDragAmount -> onExpand()
// dragAmount < -minDragAmount -> onCollapse()
// }
// }
// },
// shape = shape,
// content = content
// )
}

View File

@@ -25,9 +25,11 @@ import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalPagerApi::class)
@Composable

View File

@@ -7,13 +7,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalPagerApi::class)
@Composable

View File

@@ -2,14 +2,12 @@ package com.owenlejeune.tvtime.ui.components
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.magnifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
@@ -28,10 +26,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
@@ -39,8 +35,6 @@ import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePerson
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Person
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
@@ -48,35 +42,14 @@ import com.owenlejeune.tvtime.extensions.header
import com.owenlejeune.tvtime.extensions.lazyPagingItems
import com.owenlejeune.tvtime.extensions.listItems
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import org.koin.androidx.compose.koinViewModel
import org.koin.java.KoinJavaComponent.get
private val POSTER_WIDTH = 120.dp
private val POSTER_HEIGHT = 180.dp
@Composable
fun PosterGrid(
fetchMedia: (MutableState<List<TmdbItem>>) -> Unit = {},
onClick: (Int) -> Unit = {}
) {
val mediaList = remember { mutableStateOf(emptyList<TmdbItem>()) }
fetchMedia(mediaList)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = POSTER_WIDTH),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
listItems(mediaList.value) { item ->
PosterItem(
modifier = Modifier.padding(5.dp),
mediaItem = item,
onClick = onClick
)
}
}
}
@Composable
fun PagingPosterGrid(
lazyPagingItems: LazyPagingItems<TmdbItem>?,
@@ -148,34 +121,6 @@ fun PagingPeoplePosterGrid(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PeoplePosterGrid(
fetchPeople: (MutableState<List<HomePagePerson>>) -> Unit = {},
onClick: (Int) -> Unit = {}
) {
val peopleList = remember { mutableStateOf(emptyList<HomePagePerson>()) }
fetchPeople(peopleList)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = POSTER_WIDTH),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
listItems(peopleList.value) { person ->
PosterItem(
url = TmdbUtils.getFullPersonImagePath(person.profilePath),
placeholder = Icons.Filled.Person,
modifier = Modifier.padding(5.dp),
onClick = {
onClick(person.id)
},
title = person.name
)
}
}
}
@Composable
fun PosterItem(
modifier: Modifier = Modifier,
@@ -198,27 +143,6 @@ fun PosterItem(
)
}
@Composable
fun PosterItem(
modifier: Modifier = Modifier,
width: Dp = POSTER_WIDTH,
onClick: (id: Int) -> Unit = {},
person: Person?
) {
PosterItem(
modifier = modifier,
width = width,
onClick = {
person?.let {
onClick(person.id)
}
},
url = person?.let { TmdbUtils.getFullPersonImagePath(person) },
elevation = 8.dp,
title = person?.name
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PosterItem(

View File

@@ -122,7 +122,7 @@ fun ScrollableTabs(
}
@Composable
private fun SmallTabIndicator(
fun SmallTabIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
@@ -66,6 +67,7 @@ import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AuthorDetails
import com.owenlejeune.tvtime.extensions.toDp
import com.owenlejeune.tvtime.extensions.unlessEmpty
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
@@ -1099,4 +1101,69 @@ fun SearchBar(
@Composable
fun MyDivider(modifier: Modifier = Modifier) {
Divider(thickness = 0.5.dp, modifier = modifier, color = MaterialTheme.colorScheme.secondaryContainer)
}
@Composable
fun SelectableTextItem(
selected: Boolean,
onSelected: () -> Unit,
text: String,
selectedColor: Color = MaterialTheme.colorScheme.secondary,
unselectedColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onSelected)
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp)
) {
val size = remember { mutableStateOf(IntSize.Zero) }
val color = if (selected) selectedColor else unselectedColor
Text(
text = text,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = color,
modifier = Modifier
.padding(horizontal = 4.dp)
.onGloballyPositioned { size.value = it.size }
)
Box(
modifier = Modifier
.height(height = if (selected) 3.dp else 1.dp)
.width(width = size.value.width.toDp().plus(8.dp))
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(color = color)
)
}
}
}
@Composable
fun SelectableTextChip(
selected: Boolean,
onSelected: () -> Unit,
text: String,
selectedColor: Color = MaterialTheme.colorScheme.secondary,
unselectedColor: Color = MaterialTheme.colorScheme.surfaceVariant
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(percent = 25))
.border(width = 1.dp, color = selectedColor, shape = RoundedCornerShape(percent = 25))
.background(color = if(selected) selectedColor else unselectedColor)
.clickable(onClick = onSelected)
) {
Text(
text = text,
color = if (selected) unselectedColor else selectedColor,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.Center)
.padding(12.dp)
)
}
}

View File

@@ -30,18 +30,18 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.AccountList
import com.owenlejeune.tvtime.ui.viewmodel.RecommendedMediaViewModel
import com.owenlejeune.tvtime.extensions.unlessEmpty
import com.owenlejeune.tvtime.ui.components.AccountIcon
import com.owenlejeune.tvtime.ui.components.PagingPosterGrid
import com.owenlejeune.tvtime.ui.navigation.AccountTabNavItem
import com.owenlejeune.tvtime.ui.navigation.ListFetchFun
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.ui.components.PagingPosterGrid
import com.owenlejeune.tvtime.ui.components.ScrollableTabs
import com.owenlejeune.tvtime.ui.navigation.AccountTabNavItem
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.navigation.ListFetchFun
import com.owenlejeune.tvtime.ui.viewmodel.RecommendedMediaViewModel
import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.launch
import kotlin.reflect.KClass

View File

@@ -43,14 +43,14 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.model.*
import com.owenlejeune.tvtime.extensions.WindowSizeClass
import com.owenlejeune.tvtime.extensions.unlessEmpty
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.components.RatingView
import com.owenlejeune.tvtime.ui.components.Spinner
import com.owenlejeune.tvtime.ui.components.SwitchPreference
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.ui.components.RatingView
import com.owenlejeune.tvtime.ui.theme.*
import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
import de.charlex.compose.RevealDirection
import de.charlex.compose.RevealSwipe
import kotlinx.coroutines.CoroutineScope

View File

@@ -1,6 +1,8 @@
package com.owenlejeune.tvtime.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.*
import androidx.compose.animation.core.LinearEasing
@@ -8,6 +10,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -16,6 +19,7 @@ import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material3.*
import androidx.compose.material.TabRow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -30,15 +34,19 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEachIndexed
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.owenlejeune.tvtime.R
@@ -60,10 +68,13 @@ 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.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.*
import okhttp3.internal.notify
import org.json.JSONObject
import org.koin.androidx.compose.koinViewModel
import org.koin.java.KoinJavaComponent.get
import java.text.DecimalFormat
@@ -200,7 +211,6 @@ private fun MediaViewContent(
)
Column(
// modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (type == MediaViewType.MOVIE) {
@@ -442,6 +452,8 @@ private fun MainContent(
VideosCard(itemId = itemId, service = service, modifier = Modifier.fillMaxWidth())
AdditionalDetailsCard(itemId = itemId, mediaItem = mediaItem, service = service, type = type)
WatchProvidersCard(itemId = itemId, service = service)
if (windowSize != WindowSizeClass.Expanded) {
ReviewsCard(itemId = itemId, service = service)
@@ -966,7 +978,7 @@ private fun AdditionalDetailsCard(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 12.dp, horizontal = 16.dp),
.padding(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AdditionalDetailItem(
@@ -1072,12 +1084,14 @@ private fun AdditionalDetailItem(
Text(
text = title,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
text = subtext,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp)
)
if (includeDivider) {
Divider()
@@ -1218,7 +1232,7 @@ fun VideosCard(
Text(
text = stringResource(id = R.string.videos_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
@@ -1250,7 +1264,7 @@ private fun VideoGroup(results: List<Video>, type: Video.Type, title: String) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
modifier = Modifier.padding(start = 12.dp, top = 8.dp)
)
val posterWidth = 120.dp
@@ -1277,6 +1291,136 @@ private fun VideoGroup(results: List<Video>, type: Video.Type, title: String) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WatchProvidersCard(
itemId: Int?,
service: DetailService,
modifier: Modifier = Modifier
) {
val watchProviders = remember { mutableStateOf<WatchProviders?>(null) }
itemId?.let {
if (watchProviders.value == null) {
CoroutineScope(Dispatchers.IO).launch {
val response = service.getWatchProviders(it)
if (response.isSuccessful) {
val results = response.body()?.results
results?.get(Locale.current.region)?.let { watchProviders.value = it }
}
}
}
}
watchProviders.value?.let { providers ->
if (providers.buy?.isNotEmpty() == true || providers.rent?.isNotEmpty() == true || providers.flaterate?.isNotEmpty() == true) {
Card(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = stringResource(R.string.watch_providers_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
var selectedIndex by remember { mutableStateOf(
when {
providers.flaterate != null -> 0
providers.rent != null -> 1
providers.buy != null -> 2
else -> -1
}
) }
Row(
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
providers.flaterate?.let {
SelectableTextChip(
selected = selectedIndex == 0,
onSelected = { selectedIndex = 0 },
text = stringResource(R.string.streaming_label)
)
}
providers.rent?.let {
SelectableTextChip(
selected = selectedIndex == 1,
onSelected = { selectedIndex = 1 },
text = stringResource(R.string.rent_label)
)
}
providers.buy?.let {
SelectableTextChip(
selected = selectedIndex == 2,
onSelected = { selectedIndex = 2 },
text = stringResource(R.string.buy_label)
)
}
}
Crossfade(
modifier = modifier.padding(top = 4.dp, bottom = 12.dp),
targetState = selectedIndex
) { index ->
when (index) {
0 -> WatchProviderContainer(watchProviders = providers.flaterate!!, link = providers.link)
1 -> WatchProviderContainer(watchProviders = providers.rent!!, link = providers.link)
2 -> WatchProviderContainer(watchProviders = providers.buy!!, link = providers.link)
}
}
}
}
}
}
@Composable
private fun WatchProviderContainer(
watchProviders: List<WatchProviderDetails>,
link: String
) {
val context = LocalContext.current
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
mainAxisSpacing = 8.dp,
crossAxisSpacing = 4.dp
) {
watchProviders
.sortedBy { it.displayPriority }
.forEach { item ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
context.startActivity(intent)
}
) {
AsyncImage(
model = TmdbUtils.fullImagePath(item.logoPath),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
)
Text(
text = item.providerName,
fontSize = 10.sp,
modifier = Modifier.width(48.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun ReviewsCard(
itemId: Int?,

View File

@@ -26,12 +26,12 @@ 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.PersonCreditsResponse
import com.owenlejeune.tvtime.ui.components.ContentCard
import com.owenlejeune.tvtime.ui.components.DetailHeader
import com.owenlejeune.tvtime.ui.components.ExpandableContentCard
import com.owenlejeune.tvtime.ui.components.TwoLineImageTextCard
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.components.DetailHeader
import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -105,7 +105,9 @@ fun PersonDetailScreen(
}
}
ContentCard(title = stringResource(R.string.known_for_label)) {
ContentCard(
title = stringResource(R.string.known_for_label)
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()

View File

@@ -27,8 +27,8 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.TvService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.extensions.listItems
import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

View File

@@ -45,6 +45,7 @@ import com.owenlejeune.tvtime.ui.views.ItemMoveCallback
import com.owenlejeune.tvtime.utils.ResourceUtils
import com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.java.KoinJavaComponent.get
@@ -224,7 +225,7 @@ private fun TopLevelSettingsCard(
@Composable
private fun SearchPreferences() {
val settingsViewModel = viewModel<SettingsViewModel>()
val settingsViewModel = koinViewModel<SettingsViewModel>()
SwitchPreference(
titleText = stringResource(R.string.preferences_persistent_search_title),

View File

@@ -1,20 +1,38 @@
package com.owenlejeune.tvtime.utils
import androidx.compose.ui.text.intl.Locale
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.AuthorDetails
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedMovie
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedTv
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Image
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieReleaseResults
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Person
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Status
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvContentRatings
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video
import java.text.SimpleDateFormat
object TmdbUtils {
private const val POSTER_BASE = "https://image.tmdb.org/t/p/original"
private const val BACKDROP_BASE = "https://www.themoviedb.org/t/p/original"
private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2"
// private const val BACKDROP_BASE = "https://www.themoviedb.org/t/p/original"
// private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2"
private const val GRAVATAR_BASE = "https://www.gravatar.com/avatar/"
private const val AVATAR_BASE = "https://www.themoviedb.org/t/p/w150_and_h150_face"
private const val STILL_BASE = "https://www.themoviedb.org/t/p/w454_and_h254_bestv2/"
// private const val AVATAR_BASE = "https://www.themoviedb.org/t/p/w150_and_h150_face"
// private const val STILL_BASE = "https://www.themoviedb.org/t/p/w454_and_h254_bestv2/"
private const val BACKDROP_BASE = POSTER_BASE
private const val PERSON_BASE = POSTER_BASE
private const val AVATAR_BASE = POSTER_BASE
private const val STILL_BASE = POSTER_BASE
private const val DEF_REGION = "US"
fun fullImagePath(sourcePath: String) = POSTER_BASE.plus(sourcePath)
fun getFullPosterPath(posterPath: String?): String? {
return posterPath?.let {
if (posterPath.isEmpty()) null else "${POSTER_BASE}${posterPath}"
@@ -203,7 +221,7 @@ object TmdbUtils {
return ""
}
fun formatRevenue(revenue: Int): String {
fun formatRevenue(revenue: Long): String {
val decFormat = "%.1f"
val thousands = revenue.toFloat() / 1000f
if (thousands > 1000) {

View File

@@ -221,4 +221,8 @@
<string name="app_info_label">App Info</string>
<string name="changelog_label">Changelog</string>
<string name="powered_by_tmdb">Powered by TMDB</string>
<string name="watch_providers_title">Watch Providers</string>
<string name="streaming_label">Streaming</string>
<string name="rent_label">Rent</string>
<string name="buy_label">Buy</string>
</resources>