Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 72 additions & 27 deletions Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@
* limitations under the License.
*/

@file:OptIn(ExperimentalFoundationApi::class)

package com.example.jetcaster.ui.home

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
Expand All @@ -40,8 +43,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
Expand All @@ -58,7 +63,10 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -75,10 +83,10 @@ import coil.compose.AsyncImage
import com.example.jetcaster.R
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
import com.example.jetcaster.designsystem.theme.Keyline1
import com.example.jetcaster.ui.home.discover.discoverItems
import com.example.jetcaster.ui.home.library.libraryItems
import com.example.jetcaster.ui.theme.JetcasterTheme
Expand All @@ -89,6 +97,7 @@ import java.time.Duration
import java.time.LocalDateTime
import java.time.OffsetDateTime
import kotlinx.collections.immutable.PersistentList
import kotlinx.coroutines.launch

@Composable
fun Home(
Expand All @@ -110,6 +119,7 @@ fun Home(
onPodcastUnfollowed = viewModel::onPodcastUnfollowed,
navigateToPlayer = navigateToPlayer,
onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed,
onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected,
modifier = Modifier.fillMaxSize()
)
}
Expand Down Expand Up @@ -174,7 +184,15 @@ fun Home(
onCategorySelected: (Category) -> Unit,
navigateToPlayer: (String) -> Unit,
onTogglePodcastFollowed: (String) -> Unit,
onLibraryPodcastSelected: (Podcast?) -> Unit
) {
// Effect that changes the home category selection when there are no subscribed podcasts
LaunchedEffect(key1 = featuredPodcasts) {
if (featuredPodcasts.isEmpty()) {
onHomeCategorySelected(HomeCategory.Discover)
}
}

Column(
modifier = modifier.windowInsetsPadding(
WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)
Expand Down Expand Up @@ -221,7 +239,8 @@ fun Home(
onHomeCategorySelected = onHomeCategorySelected,
onCategorySelected = onCategorySelected,
navigateToPlayer = navigateToPlayer,
onTogglePodcastFollowed = onTogglePodcastFollowed
onTogglePodcastFollowed = onTogglePodcastFollowed,
onLibraryPodcastSelected = onLibraryPodcastSelected
)
}
}
Expand All @@ -243,11 +262,21 @@ private fun HomeContent(
onCategorySelected: (Category) -> Unit,
navigateToPlayer: (String) -> Unit,
onTogglePodcastFollowed: (String) -> Unit,
onLibraryPodcastSelected: (Podcast?) -> Unit
) {
val pagerState = rememberPagerState { featuredPodcasts.size }
LaunchedEffect(pagerState, featuredPodcasts) {
snapshotFlow { pagerState.currentPage }
.collect {
val podcast = featuredPodcasts.getOrNull(pagerState.currentPage)
onLibraryPodcastSelected(podcast?.podcast)
}
}
LazyColumn(modifier = modifier.fillMaxSize()) {
if (featuredPodcasts.isNotEmpty()) {
item {
FollowedPodcastItem(
pagerState = pagerState,
items = featuredPodcasts,
onPodcastUnfollowed = onPodcastUnfollowed,
modifier = Modifier
Expand All @@ -265,7 +294,7 @@ private fun HomeContent(
// TODO show a progress indicator or similar
}

if (homeCategories.isNotEmpty()) {
if (featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()) {
stickyHeader {
HomeCategoryTabs(
categories = homeCategories,
Expand Down Expand Up @@ -298,6 +327,7 @@ private fun HomeContent(

@Composable
private fun FollowedPodcastItem(
pagerState: PagerState,
items: PersistentList<PodcastWithExtraInfo>,
onPodcastUnfollowed: (String) -> Unit,
modifier: Modifier = Modifier,
Expand All @@ -306,11 +336,10 @@ private fun FollowedPodcastItem(
Spacer(Modifier.height(16.dp))

FollowedPodcasts(
pagerState = pagerState,
items = items,
onPodcastUnfollowed = onPodcastUnfollowed,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
modifier = Modifier.fillMaxWidth()
)

Spacer(Modifier.height(16.dp))
Expand Down Expand Up @@ -367,33 +396,48 @@ fun HomeCategoryTabIndicator(
)
}

private val FEATURED_PODCAST_IMAGE_WIDTH_DP = 160.dp
private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FollowedPodcasts(
pagerState: PagerState,
items: PersistentList<PodcastWithExtraInfo>,
modifier: Modifier = Modifier,
onPodcastUnfollowed: (String) -> Unit,
) {
// TODO: Update this component to a carousel once better support is available
val lastIndex = items.size - 1
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(
start = Keyline1,
top = 16.dp,
end = Keyline1,
)
) {
itemsIndexed(items) { index: Int,
(podcast, lastEpisodeDate): PodcastWithExtraInfo ->
val coroutineScope = rememberCoroutineScope()
// TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute
// the content padding. This should be revisited once a carousel component is available.
// Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition`
// which solves this problem and avoids this calculation altogether. Once 1.7.0 is
// stable, this implementation can be updated.
BoxWithConstraints(modifier) {
val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(
horizontal = horizontalPadding,
vertical = 16.dp,
),
pageSpacing = 24.dp,
pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_WIDTH_DP)
) { page ->
val (podcast, lastEpisodeDate) = items[page]
FollowedPodcastCarouselItem(
podcastImageUrl = podcast.imageUrl,
podcastTitle = podcast.title,
onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) },
lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) },
modifier = Modifier.padding(4.dp)
modifier = Modifier
.fillMaxSize()
.clickable {
coroutineScope.launch {
pagerState.animateScrollToPage(page)
}
}
)

if (index < lastIndex) Spacer(Modifier.width(24.dp))
}
}
}
Expand All @@ -409,9 +453,9 @@ private fun FollowedPodcastCarouselItem(
Column(modifier) {
Box(
Modifier
.weight(1f)
.height(FEATURED_PODCAST_IMAGE_HEIGHT_DP)
.width(FEATURED_PODCAST_IMAGE_WIDTH_DP)
.align(Alignment.CenterHorizontally)
.aspectRatio(1f)
) {
if (podcastImageUrl != null) {
AsyncImage(
Expand Down Expand Up @@ -484,7 +528,8 @@ fun PreviewHomeContent() {
onPodcastUnfollowed = {},
navigateToPlayer = {},
onHomeCategorySelected = {},
onTogglePodcastFollowed = {}
onTogglePodcastFollowed = {},
onLibraryPodcastSelected = {}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.di.Graph
import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase
import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase
import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
import com.example.jetcaster.util.combine
Expand All @@ -44,13 +46,16 @@ import kotlinx.coroutines.launch
class HomeViewModel(
private val podcastsRepository: PodcastsRepository = Graph.podcastRepository,
private val podcastStore: PodcastStore = Graph.podcastStore,
private val episodeStore: EpisodeStore = Graph.episodeStore,
private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase =
Graph.getLatestFollowedEpisodesUseCase,
private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase =
Graph.podcastCategoryFilterUseCase,
private val filterableCategoriesUseCase: FilterableCategoriesUseCase =
Graph.filterableCategoriesUseCase
) : ViewModel() {
// Holds our currently selected podcast in the library
private val selectedLibraryPodcast = MutableStateFlow<Podcast?>(null)
// Holds our currently selected home category
private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover)
// Holds the currently available home categories
Expand All @@ -72,17 +77,22 @@ class HomeViewModel(
combine(
homeCategories,
selectedHomeCategory,
podcastStore.followedPodcastsSortedByLastEpisode(limit = 20),
podcastStore.followedPodcastsSortedByLastEpisode(limit = 10),
refreshing,
_selectedCategory.flatMapLatest { selectedCategory ->
filterableCategoriesUseCase(selectedCategory)
},
_selectedCategory.flatMapLatest {
podcastCategoryFilterUseCase(it)
},
getLatestFollowedEpisodesUseCase()
selectedLibraryPodcast.flatMapLatest {
episodeStore.episodesInPodcast(
podcastUri = it?.uri ?: "",
limit = 20
)
}
) { homeCategories,
selectedHomeCategory,
homeCategory,
podcasts,
refreshing,
filterableCategories,
Expand All @@ -91,9 +101,14 @@ class HomeViewModel(

_selectedCategory.value = filterableCategories.selectedCategory

// Override selected home category to show 'DISCOVER' if there are no
// featured podcasts
selectedHomeCategory.value =
if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory

HomeViewState(
homeCategories = homeCategories,
selectedHomeCategory = selectedHomeCategory,
selectedHomeCategory = homeCategory,
featuredPodcasts = podcasts.toPersistentList(),
refreshing = refreshing,
filterableCategoriesModel = filterableCategories,
Expand Down Expand Up @@ -143,6 +158,10 @@ class HomeViewModel(
podcastStore.togglePodcastFollowed(podcastUri)
}
}

fun onLibraryPodcastSelected(podcast: Podcast?) {
selectedLibraryPodcast.value = podcast
}
}

enum class HomeCategory {
Expand Down