diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 5d3cda2a9..f7a102a8a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -74,8 +75,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.jetcaster.R +import com.example.jetcaster.data.Category import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.ui.home.discover.Discover +import com.example.jetcaster.ui.home.category.PodcastCategoryViewState +import com.example.jetcaster.ui.home.discover.DiscoverViewState +import com.example.jetcaster.ui.home.discover.discoverItems import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.ui.theme.Keyline1 import com.example.jetcaster.ui.theme.MinContrastOfPrimaryVsSurface @@ -97,14 +101,18 @@ fun Home( ) { val viewState by viewModel.state.collectAsStateWithLifecycle() Surface(Modifier.fillMaxSize()) { - HomeContent( + Home( featuredPodcasts = viewState.featuredPodcasts, isRefreshing = viewState.refreshing, homeCategories = viewState.homeCategories, selectedHomeCategory = viewState.selectedHomeCategory, - onCategorySelected = viewModel::onHomeCategorySelected, + discoverViewState = viewState.discoverViewState, + podcastCategoryViewState = viewState.podcastCategoryViewState, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, onPodcastUnfollowed = viewModel::onPodcastUnfollowed, navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, modifier = Modifier.fillMaxSize() ) } @@ -158,15 +166,19 @@ fun HomeAppBar( @OptIn(ExperimentalFoundationApi::class) @Composable -fun HomeContent( +fun Home( featuredPodcasts: PersistentList, isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, modifier: Modifier = Modifier, onPodcastUnfollowed: (String) -> Unit, - onCategorySelected: (HomeCategory) -> Unit, - navigateToPlayer: (String) -> Unit + onHomeCategorySelected: (HomeCategory) -> Unit, + onCategorySelected: (Category) -> Unit, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { Column( modifier = modifier.windowInsetsPadding( @@ -198,14 +210,13 @@ fun HomeContent( } } + val scrimColor = MaterialTheme.colors.primary.copy(alpha = 0.38f) + + // Top Bar Column( modifier = Modifier .fillMaxWidth() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.38f), - startYPercentage = 1f, - endYPercentage = 0f - ) + .background(color = scrimColor) ) { // Draw a scrim over the status bar which matches the app bar Spacer( @@ -214,27 +225,65 @@ fun HomeContent( .fillMaxWidth() .windowInsetsTopHeight(WindowInsets.statusBars) ) - HomeAppBar( backgroundColor = appBarColor, modifier = Modifier.fillMaxWidth() ) + } - if (featuredPodcasts.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - - FollowedPodcasts( - items = featuredPodcasts, - pagerState = pagerState, - onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .padding(start = Keyline1, top = 16.dp, end = Keyline1) - .fillMaxWidth() - .height(200.dp) - ) + // Main Content + HomeContent( + featuredPodcasts = featuredPodcasts, + isRefreshing = isRefreshing, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + scrimColor = scrimColor, + pagerState = pagerState, + onPodcastUnfollowed = onPodcastUnfollowed, + onHomeCategorySelected = onHomeCategorySelected, + onCategorySelected = onCategorySelected, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } + } +} - Spacer(Modifier.height(16.dp)) - } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeContent( + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, + scrimColor: Color, + pagerState: PagerState, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (String) -> Unit, + onHomeCategorySelected: (HomeCategory) -> Unit, + onCategorySelected: (Category) -> Unit, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, +) { + LazyColumn(modifier = modifier.fillMaxSize()) { + if (featuredPodcasts.isNotEmpty()) { + item { + FollowedPodcastItem( + items = featuredPodcasts, + pagerState = pagerState, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .fillMaxWidth() + .verticalGradientScrim( + color = scrimColor, + startYPercentage = 1f, + endYPercentage = 0f + ) + ) } } @@ -243,11 +292,13 @@ fun HomeContent( } if (homeCategories.isNotEmpty()) { - HomeCategoryTabs( - categories = homeCategories, - selectedCategory = selectedHomeCategory, - onCategorySelected = onCategorySelected - ) + stickyHeader { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + onCategorySelected = onHomeCategorySelected + ) + } } when (selectedHomeCategory) { @@ -256,17 +307,43 @@ fun HomeContent( } HomeCategory.Discover -> { - Discover( + discoverItems( + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, navigateToPlayer = navigateToPlayer, - Modifier - .fillMaxWidth() - .weight(1f) + onCategorySelected = onCategorySelected, + onTogglePodcastFollowed = onTogglePodcastFollowed ) } } } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FollowedPodcastItem( + items: PersistentList, + pagerState: PagerState, + onPodcastUnfollowed: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Spacer(Modifier.height(16.dp)) + + FollowedPodcasts( + items = items, + pagerState = pagerState, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .padding(start = Keyline1, top = 16.dp, end = Keyline1) + .fillMaxWidth() + .height(200.dp) + ) + + Spacer(Modifier.height(16.dp)) + } +} + @Composable private fun HomeCategoryTabs( categories: List, @@ -410,23 +487,31 @@ private fun lastUpdated(updated: OffsetDateTime): String { } } -/* -TODO: Fix preview error @Composable @Preview fun PreviewHomeContent() { JetcasterTheme { - HomeContent( + Home( featuredPodcasts = PreviewPodcastsWithExtraInfo, isRefreshing = false, - homeCategories = HomeCategory.values().asList(), + homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, + discoverViewState = DiscoverViewState( + categories = PreviewCategories, + selectedCategory = PreviewCategories.first(), + ), + podcastCategoryViewState = PodcastCategoryViewState( + topPodcasts = PreviewPodcastsWithExtraInfo, + episodes = PreviewEpisodeToPodcasts, + ), onCategorySelected = {}, - onPodcastUnfollowed = {} + onPodcastUnfollowed = {}, + navigateToPlayer = {}, + onHomeCategorySelected = {}, + onTogglePodcastFollowed = {} ) } } -*/ @Composable @Preview diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 853b3bd4a..a4f61f90b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -19,33 +19,84 @@ package com.example.jetcaster.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.Graph +import com.example.jetcaster.data.Category +import com.example.jetcaster.data.CategoryStore import com.example.jetcaster.data.PodcastStore import com.example.jetcaster.data.PodcastWithExtraInfo import com.example.jetcaster.data.PodcastsRepository +import com.example.jetcaster.ui.home.category.PodcastCategoryViewState +import com.example.jetcaster.ui.home.discover.DiscoverViewState +import com.example.jetcaster.util.combine import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class HomeViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val categoryStore: CategoryStore = Graph.categoryStore, private val podcastStore: PodcastStore = Graph.podcastStore ) : ViewModel() { // Holds our currently selected home category - private val selectedCategory = MutableStateFlow(HomeCategory.Discover) + private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover) // Holds the currently available home categories - private val categories = MutableStateFlow(HomeCategory.values().asList()) - + private val homeCategories = MutableStateFlow(HomeCategory.entries) + // Holds our currently selected category + private val _selectedCategory = MutableStateFlow(null) // Holds our view state which the UI collects via [state] private val _state = MutableStateFlow(HomeViewState()) - + // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) + private val discover = combine( + categoryStore.categoriesSortedByPodcastCount() + .onEach { categories -> + // If we haven't got a selected category yet, select the first + if (categories.isNotEmpty() && _selectedCategory.value == null) { + _selectedCategory.value = categories[0] + } + }, + _selectedCategory + ) { categories, selectedCategory -> + DiscoverViewState( + categories = categories, + selectedCategory = selectedCategory + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastCategory = _selectedCategory.flatMapLatest { category -> + if (category == null) { + return@flatMapLatest flowOf(PodcastCategoryViewState()) + } + + val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( + category.id, + limit = 10 + ) + + val episodesFlow = categoryStore.episodesFromPodcastsInCategory( + category.id, + limit = 20 + ) + + // Combine our flows and collect them into the view state StateFlow + combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> + PodcastCategoryViewState( + topPodcasts = topPodcasts, + episodes = episodes + ) + } + } + val state: StateFlow get() = _state @@ -54,17 +105,26 @@ class HomeViewModel( // Combines the latest value from each of the flows, allowing us to generate a // view state instance which only contains the latest values. combine( - categories, - selectedCategory, + homeCategories, + selectedHomeCategory, podcastStore.followedPodcastsSortedByLastEpisode(limit = 20), - refreshing - ) { categories, selectedCategory, podcasts, refreshing -> + refreshing, + discover, + podcastCategory + ) { homeCategories, + selectedHomeCategory, + podcasts, + refreshing, + discoverViewState, + podcastCategoryViewState -> HomeViewState( - homeCategories = categories, - selectedHomeCategory = selectedCategory, + homeCategories = homeCategories, + selectedHomeCategory = selectedHomeCategory, featuredPodcasts = podcasts.toPersistentList(), refreshing = refreshing, - errorMessage = null /* TODO */ + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + errorMessage = null, /* TODO */ ) }.catch { throwable -> // TODO: emit a UI error here. For now we'll just rethrow @@ -89,8 +149,12 @@ class HomeViewModel( } } + fun onCategorySelected(category: Category) { + _selectedCategory.value = category + } + fun onHomeCategorySelected(category: HomeCategory) { - selectedCategory.value = category + selectedHomeCategory.value = category } fun onPodcastUnfollowed(podcastUri: String) { @@ -98,6 +162,12 @@ class HomeViewModel( podcastStore.unfollowPodcast(podcastUri) } } + + fun onTogglePodcastFollowed(podcastUri: String) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastUri) + } + } } enum class HomeCategory { @@ -109,5 +179,7 @@ data class HomeViewState( val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), + val discoverViewState: DiscoverViewState = DiscoverViewState(), + val podcastCategoryViewState: PodcastCategoryViewState = PodcastCategoryViewState(), val errorMessage: String? = null ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt index b67543a91..3a6d96ecc 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt @@ -18,10 +18,12 @@ package com.example.jetcaster.ui.home import com.example.jetcaster.data.Category import com.example.jetcaster.data.Episode +import com.example.jetcaster.data.EpisodeToPodcast import com.example.jetcaster.data.Podcast import com.example.jetcaster.data.PodcastWithExtraInfo import java.time.OffsetDateTime import java.time.ZoneOffset +import kotlinx.collections.immutable.toPersistentList val PreviewCategories = listOf( Category(name = "Crime"), @@ -48,7 +50,7 @@ val PreviewPodcastsWithExtraInfo = PreviewPodcasts.mapIndexed { index, podcast - this.lastEpisodeDate = OffsetDateTime.now() this.isFollowed = index % 2 == 0 } -} +}.toPersistentList() val PreviewEpisodes = listOf( Episode( @@ -63,3 +65,10 @@ val PreviewEpisodes = listOf( ) ) ) + +val PreviewEpisodeToPodcasts = listOf( + EpisodeToPodcast().apply { + episode = PreviewEpisodes.first() + _podcasts = PreviewPodcasts + } +) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt index 1b82eb09b..5bc403292 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -19,7 +19,6 @@ package com.example.jetcaster.ui.home.category import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -30,7 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -49,7 +48,6 @@ import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,12 +65,11 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension.Companion.fillToConstraints import androidx.constraintlayout.compose.Dimension.Companion.preferredWrapContent -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.data.Episode +import com.example.jetcaster.data.EpisodeToPodcast import com.example.jetcaster.data.Podcast import com.example.jetcaster.data.PodcastWithExtraInfo import com.example.jetcaster.ui.home.PreviewEpisodes @@ -80,59 +77,37 @@ import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.ui.theme.Keyline1 import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import com.example.jetcaster.util.viewModelProviderFactoryOf import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -@Composable -fun PodcastCategory( - categoryId: Long, +fun LazyListScope.podcastCategory( + topPodcasts: List, + episodes: List, navigateToPlayer: (String) -> Unit, - modifier: Modifier = Modifier + onTogglePodcastFollowed: (String) -> Unit, ) { - /** - * CategoryEpisodeListViewModel requires the category as part of it's constructor, therefore - * we need to assist with it's instantiation with a custom factory and custom key. - */ - val viewModel: PodcastCategoryViewModel = viewModel( - // We use a custom key, using the category parameter - key = "category_list_$categoryId", - factory = viewModelProviderFactoryOf { PodcastCategoryViewModel(categoryId) } - ) - - val viewState by viewModel.state.collectAsStateWithLifecycle() - - /** - * TODO: reset scroll position when category changes - */ - LazyColumn( - contentPadding = PaddingValues(0.dp), - verticalArrangement = Arrangement.Center, - modifier = modifier - ) { - item { - CategoryPodcasts(viewState.topPodcasts, viewModel) - } + item { + CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + } - items(viewState.episodes, key = { it.episode.uri }) { item -> - EpisodeListItem( - episode = item.episode, - podcast = item.podcast, - onClick = navigateToPlayer, - modifier = Modifier.fillParentMaxWidth() - ) - } + items(episodes, key = { it.episode.uri }) { item -> + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + modifier = Modifier.fillParentMaxWidth() + ) } } @Composable private fun CategoryPodcasts( topPodcasts: List, - viewModel: PodcastCategoryViewModel + onTogglePodcastFollowed: (String) -> Unit ) { CategoryPodcastRow( podcasts = topPodcasts, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + onTogglePodcastFollowed = onTogglePodcastFollowed, modifier = Modifier.fillMaxWidth() ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt index 6bac1984e..c957e9bc1 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt @@ -60,12 +60,6 @@ class PodcastCategoryViewModel( }.collect { _state.value = it } } } - - fun onTogglePodcastFollowed(podcastUri: String) { - viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcastUri) - } - } } data class PodcastCategoryViewState( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt index 24632dc14..638ea2fb2 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -16,13 +16,11 @@ package com.example.jetcaster.ui.home.discover -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.MaterialTheme import androidx.compose.material.ScrollableTabRow import androidx.compose.material.Surface @@ -30,56 +28,49 @@ import androidx.compose.material.Tab import androidx.compose.material.TabPosition import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.example.jetcaster.data.Category -import com.example.jetcaster.ui.home.category.PodcastCategory +import com.example.jetcaster.ui.home.category.PodcastCategoryViewState +import com.example.jetcaster.ui.home.category.podcastCategory import com.example.jetcaster.ui.theme.Keyline1 -@Composable -fun Discover( +data class DiscoverViewState( + val categories: List = emptyList(), + val selectedCategory: Category? = null +) + +fun LazyListScope.discoverItems( + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, navigateToPlayer: (String) -> Unit, - modifier: Modifier = Modifier + onCategorySelected: (Category) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - val viewModel: DiscoverViewModel = viewModel() - val viewState by viewModel.state.collectAsStateWithLifecycle() - - val selectedCategory = viewState.selectedCategory - - if (viewState.categories.isNotEmpty() && selectedCategory != null) { - Column(modifier) { - Spacer(Modifier.height(8.dp)) + if (discoverViewState.categories.isEmpty() || discoverViewState.selectedCategory == null) { + // TODO: empty state + return + } - PodcastCategoryTabs( - categories = viewState.categories, - selectedCategory = selectedCategory, - onCategorySelected = viewModel::onCategorySelected, - modifier = Modifier.fillMaxWidth() - ) + item { + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) + PodcastCategoryTabs( + categories = discoverViewState.categories, + selectedCategory = discoverViewState.selectedCategory, + onCategorySelected = onCategorySelected, + modifier = Modifier.fillMaxWidth() + ) - Crossfade( - targetState = selectedCategory, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { category -> - /** - * TODO, need to think about how this will scroll within the outer VerticalScroller - */ - PodcastCategory( - categoryId = category.id, - navigateToPlayer = navigateToPlayer, - modifier = Modifier.fillMaxSize() - ) - } - } + Spacer(Modifier.height(8.dp)) } - // TODO: empty state + + podcastCategory( + topPodcasts = podcastCategoryViewState.topPodcasts, + episodes = podcastCategoryViewState.episodes, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) } private val emptyTabIndicator: @Composable (List) -> Unit = {} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt deleted file mode 100644 index d21d83392..000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home.discover - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.jetcaster.Graph -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.CategoryStore -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -class DiscoverViewModel( - private val categoryStore: CategoryStore = Graph.categoryStore -) : ViewModel() { - // Holds our currently selected category - private val _selectedCategory = MutableStateFlow(null) - - // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(DiscoverViewState()) - - val state: StateFlow - get() = _state - - init { - viewModelScope.launch { - // Combines the latest value from each of the flows, allowing us to generate a - // view state instance which only contains the latest values. - combine( - categoryStore.categoriesSortedByPodcastCount() - .onEach { categories -> - // If we haven't got a selected category yet, select the first - if (categories.isNotEmpty() && _selectedCategory.value == null) { - _selectedCategory.value = categories[0] - } - }, - _selectedCategory - ) { categories, selectedCategory -> - DiscoverViewState( - categories = categories, - selectedCategory = selectedCategory - ) - }.collect { _state.value = it } - } - } - - fun onCategorySelected(category: Category) { - _selectedCategory.value = category - } -} - -data class DiscoverViewState( - val categories: List = emptyList(), - val selectedCategory: Category? = null -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt new file mode 100644 index 000000000..5d6b11493 --- /dev/null +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Flows.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import kotlinx.coroutines.flow.Flow + +/** + * Combines six flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param flow4 The fourth flow. + * @param flow5 The fifth flow. + * @param flow6 The sixth flow. + * @param transform The transform function to combine the latest values of the six flows. + * @return A flow that emits the results of the transform function applied to the latest values of the six flows. + */ +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = + kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) + }