Skip to content

Commit c9537eb

Browse files
committed
[Jetcaster]: Handle empty library state.
1 parent ccdb03d commit c9537eb

File tree

2 files changed

+100
-35
lines changed

2 files changed

+100
-35
lines changed

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@
1414
* limitations under the License.
1515
*/
1616

17+
@file:OptIn(ExperimentalFoundationApi::class)
18+
1719
package com.example.jetcaster.ui.home
1820

1921
import androidx.compose.foundation.ExperimentalFoundationApi
2022
import androidx.compose.foundation.Image
2123
import androidx.compose.foundation.background
24+
import androidx.compose.foundation.clickable
2225
import androidx.compose.foundation.layout.Box
2326
import androidx.compose.foundation.layout.Column
2427
import androidx.compose.foundation.layout.PaddingValues
2528
import androidx.compose.foundation.layout.Row
2629
import androidx.compose.foundation.layout.Spacer
2730
import androidx.compose.foundation.layout.WindowInsets
2831
import androidx.compose.foundation.layout.WindowInsetsSides
29-
import androidx.compose.foundation.layout.aspectRatio
3032
import androidx.compose.foundation.layout.fillMaxSize
3133
import androidx.compose.foundation.layout.fillMaxWidth
3234
import androidx.compose.foundation.layout.height
@@ -40,8 +42,10 @@ import androidx.compose.foundation.layout.width
4042
import androidx.compose.foundation.layout.windowInsetsPadding
4143
import androidx.compose.foundation.layout.windowInsetsTopHeight
4244
import androidx.compose.foundation.lazy.LazyColumn
43-
import androidx.compose.foundation.lazy.LazyRow
44-
import androidx.compose.foundation.lazy.itemsIndexed
45+
import androidx.compose.foundation.pager.HorizontalPager
46+
import androidx.compose.foundation.pager.PageSize
47+
import androidx.compose.foundation.pager.PagerState
48+
import androidx.compose.foundation.pager.rememberPagerState
4549
import androidx.compose.foundation.shape.RoundedCornerShape
4650
import androidx.compose.material.icons.Icons
4751
import androidx.compose.material.icons.filled.AccountCircle
@@ -58,12 +62,20 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
5862
import androidx.compose.material3.Text
5963
import androidx.compose.material3.TopAppBar
6064
import androidx.compose.runtime.Composable
65+
import androidx.compose.runtime.LaunchedEffect
6166
import androidx.compose.runtime.getValue
67+
import androidx.compose.runtime.mutableStateOf
68+
import androidx.compose.runtime.remember
69+
import androidx.compose.runtime.rememberCoroutineScope
70+
import androidx.compose.runtime.setValue
6271
import androidx.compose.ui.Alignment
6372
import androidx.compose.ui.Modifier
6473
import androidx.compose.ui.draw.clip
6574
import androidx.compose.ui.graphics.Color
6675
import androidx.compose.ui.layout.ContentScale
76+
import androidx.compose.ui.layout.onSizeChanged
77+
import androidx.compose.ui.platform.LocalConfiguration
78+
import androidx.compose.ui.platform.LocalDensity
6779
import androidx.compose.ui.res.painterResource
6880
import androidx.compose.ui.res.stringResource
6981
import androidx.compose.ui.text.style.TextOverflow
@@ -75,20 +87,21 @@ import coil.compose.AsyncImage
7587
import com.example.jetcaster.R
7688
import com.example.jetcaster.core.data.database.model.Category
7789
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
90+
import com.example.jetcaster.core.data.database.model.Podcast
7891
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
7992
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
8093
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
81-
import com.example.jetcaster.designsystem.theme.Keyline1
8294
import com.example.jetcaster.ui.home.discover.discoverItems
8395
import com.example.jetcaster.ui.home.library.libraryItems
8496
import com.example.jetcaster.ui.theme.JetcasterTheme
8597
import com.example.jetcaster.util.ToggleFollowPodcastIconButton
8698
import com.example.jetcaster.util.quantityStringResource
8799
import com.example.jetcaster.util.verticalGradientScrim
100+
import kotlinx.collections.immutable.PersistentList
101+
import kotlinx.coroutines.launch
88102
import java.time.Duration
89103
import java.time.LocalDateTime
90104
import java.time.OffsetDateTime
91-
import kotlinx.collections.immutable.PersistentList
92105

93106
@Composable
94107
fun Home(
@@ -110,6 +123,7 @@ fun Home(
110123
onPodcastUnfollowed = viewModel::onPodcastUnfollowed,
111124
navigateToPlayer = navigateToPlayer,
112125
onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed,
126+
onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected,
113127
modifier = Modifier.fillMaxSize()
114128
)
115129
}
@@ -174,7 +188,15 @@ fun Home(
174188
onCategorySelected: (Category) -> Unit,
175189
navigateToPlayer: (String) -> Unit,
176190
onTogglePodcastFollowed: (String) -> Unit,
191+
onLibraryPodcastSelected: (Podcast?) -> Unit
177192
) {
193+
// Effect that changes the home category selection when there are no subscribed podcasts
194+
LaunchedEffect(key1 = featuredPodcasts) {
195+
if (featuredPodcasts.isEmpty()) {
196+
onHomeCategorySelected(HomeCategory.Discover)
197+
}
198+
}
199+
178200
Column(
179201
modifier = modifier.windowInsetsPadding(
180202
WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)
@@ -221,7 +243,8 @@ fun Home(
221243
onHomeCategorySelected = onHomeCategorySelected,
222244
onCategorySelected = onCategorySelected,
223245
navigateToPlayer = navigateToPlayer,
224-
onTogglePodcastFollowed = onTogglePodcastFollowed
246+
onTogglePodcastFollowed = onTogglePodcastFollowed,
247+
onLibraryPodcastSelected = onLibraryPodcastSelected
225248
)
226249
}
227250
}
@@ -243,11 +266,18 @@ private fun HomeContent(
243266
onCategorySelected: (Category) -> Unit,
244267
navigateToPlayer: (String) -> Unit,
245268
onTogglePodcastFollowed: (String) -> Unit,
269+
onLibraryPodcastSelected: (Podcast?) -> Unit
246270
) {
271+
val pagerState = rememberPagerState { featuredPodcasts.size }
272+
LaunchedEffect(pagerState.currentPage, featuredPodcasts) {
273+
val podcast = featuredPodcasts.getOrNull(pagerState.currentPage)
274+
onLibraryPodcastSelected(podcast?.podcast)
275+
}
247276
LazyColumn(modifier = modifier.fillMaxSize()) {
248277
if (featuredPodcasts.isNotEmpty()) {
249278
item {
250279
FollowedPodcastItem(
280+
pagerState = pagerState,
251281
items = featuredPodcasts,
252282
onPodcastUnfollowed = onPodcastUnfollowed,
253283
modifier = Modifier
@@ -265,7 +295,7 @@ private fun HomeContent(
265295
// TODO show a progress indicator or similar
266296
}
267297

268-
if (homeCategories.isNotEmpty()) {
298+
if (featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()) {
269299
stickyHeader {
270300
HomeCategoryTabs(
271301
categories = homeCategories,
@@ -298,6 +328,7 @@ private fun HomeContent(
298328

299329
@Composable
300330
private fun FollowedPodcastItem(
331+
pagerState: PagerState,
301332
items: PersistentList<PodcastWithExtraInfo>,
302333
onPodcastUnfollowed: (String) -> Unit,
303334
modifier: Modifier = Modifier,
@@ -306,11 +337,10 @@ private fun FollowedPodcastItem(
306337
Spacer(Modifier.height(16.dp))
307338

308339
FollowedPodcasts(
340+
pagerState = pagerState,
309341
items = items,
310342
onPodcastUnfollowed = onPodcastUnfollowed,
311-
modifier = Modifier
312-
.fillMaxWidth()
313-
.height(200.dp)
343+
modifier = Modifier.fillMaxWidth()
314344
)
315345

316346
Spacer(Modifier.height(16.dp))
@@ -367,34 +397,54 @@ fun HomeCategoryTabIndicator(
367397
)
368398
}
369399

400+
private val FEATURED_PODCAST_IMAGE_WIDTH_DP = 160.dp
401+
private val FEATURED_PODCAST_IMAGE_HEIGHT_DP = 180.dp
402+
403+
@OptIn(ExperimentalFoundationApi::class)
370404
@Composable
371405
fun FollowedPodcasts(
406+
pagerState: PagerState,
372407
items: PersistentList<PodcastWithExtraInfo>,
373408
modifier: Modifier = Modifier,
374409
onPodcastUnfollowed: (String) -> Unit,
375410
) {
376-
// TODO: Update this component to a carousel once better support is available
377-
val lastIndex = items.size - 1
378-
LazyRow(
379-
modifier = modifier,
411+
val coroutineScope = rememberCoroutineScope()
412+
413+
var horizontalPadding by remember { mutableStateOf(0.dp) }
414+
val density = LocalDensity.current
415+
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
416+
HorizontalPager(
417+
state = pagerState,
418+
modifier = modifier.onSizeChanged {size ->
419+
// TODO: this is not quite performant since it requires 2 passes to compute the content
420+
// padding. This should be revisited once a carousel component is available.
421+
// Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition`
422+
// which solves this problem and avoids this calculation altogether. Once 1.7.0 is
423+
// stable, this implementation can be updated.
424+
horizontalPadding = with(density) {
425+
(size.width.toDp() - FEATURED_PODCAST_IMAGE_WIDTH_DP) / 2
426+
}
427+
},
380428
contentPadding = PaddingValues(
381-
start = Keyline1,
382-
top = 16.dp,
383-
end = Keyline1,
429+
horizontal = horizontalPadding,
430+
vertical = 16.dp,
431+
),
432+
pageSize = PageSize.Fixed(180.dp)
433+
) { page ->
434+
val (podcast, lastEpisodeDate) = items[page]
435+
FollowedPodcastCarouselItem(
436+
podcastImageUrl = podcast.imageUrl,
437+
podcastTitle = podcast.title,
438+
onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) },
439+
lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) },
440+
modifier = Modifier
441+
.fillMaxSize()
442+
.clickable {
443+
coroutineScope.launch {
444+
pagerState.animateScrollToPage(page)
445+
}
446+
}
384447
)
385-
) {
386-
itemsIndexed(items) { index: Int,
387-
(podcast, lastEpisodeDate): PodcastWithExtraInfo ->
388-
FollowedPodcastCarouselItem(
389-
podcastImageUrl = podcast.imageUrl,
390-
podcastTitle = podcast.title,
391-
onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) },
392-
lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) },
393-
modifier = Modifier.padding(4.dp)
394-
)
395-
396-
if (index < lastIndex) Spacer(Modifier.width(24.dp))
397-
}
398448
}
399449
}
400450

@@ -409,9 +459,9 @@ private fun FollowedPodcastCarouselItem(
409459
Column(modifier) {
410460
Box(
411461
Modifier
412-
.weight(1f)
462+
.height(FEATURED_PODCAST_IMAGE_HEIGHT_DP)
463+
.width(FEATURED_PODCAST_IMAGE_WIDTH_DP)
413464
.align(Alignment.CenterHorizontally)
414-
.aspectRatio(1f)
415465
) {
416466
if (podcastImageUrl != null) {
417467
AsyncImage(
@@ -484,7 +534,8 @@ fun PreviewHomeContent() {
484534
onPodcastUnfollowed = {},
485535
navigateToPlayer = {},
486536
onHomeCategorySelected = {},
487-
onTogglePodcastFollowed = {}
537+
onTogglePodcastFollowed = {},
538+
onLibraryPodcastSelected = {}
488539
)
489540
}
490541
}

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import androidx.lifecycle.ViewModel
2020
import androidx.lifecycle.viewModelScope
2121
import com.example.jetcaster.core.data.database.model.Category
2222
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
23+
import com.example.jetcaster.core.data.database.model.Podcast
2324
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
2425
import com.example.jetcaster.core.data.di.Graph
2526
import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase
2627
import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase
2728
import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase
2829
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
2930
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
31+
import com.example.jetcaster.core.data.repository.EpisodeStore
3032
import com.example.jetcaster.core.data.repository.PodcastStore
3133
import com.example.jetcaster.core.data.repository.PodcastsRepository
3234
import com.example.jetcaster.util.combine
@@ -44,13 +46,16 @@ import kotlinx.coroutines.launch
4446
class HomeViewModel(
4547
private val podcastsRepository: PodcastsRepository = Graph.podcastRepository,
4648
private val podcastStore: PodcastStore = Graph.podcastStore,
49+
private val episodeStore: EpisodeStore = Graph.episodeStore,
4750
private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase =
4851
Graph.getLatestFollowedEpisodesUseCase,
4952
private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase =
5053
Graph.podcastCategoryFilterUseCase,
5154
private val filterableCategoriesUseCase: FilterableCategoriesUseCase =
5255
Graph.filterableCategoriesUseCase
5356
) : ViewModel() {
57+
// Holds our currently selected podcast in the library
58+
private val selectedLibraryPodcast = MutableStateFlow<Podcast?>(null)
5459
// Holds our currently selected home category
5560
private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover)
5661
// Holds the currently available home categories
@@ -72,15 +77,20 @@ class HomeViewModel(
7277
combine(
7378
homeCategories,
7479
selectedHomeCategory,
75-
podcastStore.followedPodcastsSortedByLastEpisode(limit = 20),
80+
podcastStore.followedPodcastsSortedByLastEpisode(limit = 10),
7681
refreshing,
7782
_selectedCategory.flatMapLatest { selectedCategory ->
7883
filterableCategoriesUseCase(selectedCategory)
7984
},
8085
_selectedCategory.flatMapLatest {
8186
podcastCategoryFilterUseCase(it)
8287
},
83-
getLatestFollowedEpisodesUseCase()
88+
selectedLibraryPodcast.flatMapLatest {
89+
episodeStore.episodesInPodcast(
90+
podcastUri = it?.uri ?: "",
91+
limit = 20
92+
)
93+
}
8494
) { homeCategories,
8595
selectedHomeCategory,
8696
podcasts,
@@ -143,6 +153,10 @@ class HomeViewModel(
143153
podcastStore.togglePodcastFollowed(podcastUri)
144154
}
145155
}
156+
157+
fun onLibraryPodcastSelected(podcast: Podcast?) {
158+
selectedLibraryPodcast.value = podcast
159+
}
146160
}
147161

148162
enum class HomeCategory {

0 commit comments

Comments
 (0)