14
14
* limitations under the License.
15
15
*/
16
16
17
+ @file:OptIn(ExperimentalFoundationApi ::class )
18
+
17
19
package com.example.jetcaster.ui.home
18
20
19
21
import androidx.compose.foundation.ExperimentalFoundationApi
20
22
import androidx.compose.foundation.Image
21
23
import androidx.compose.foundation.background
24
+ import androidx.compose.foundation.clickable
22
25
import androidx.compose.foundation.layout.Box
23
26
import androidx.compose.foundation.layout.Column
24
27
import androidx.compose.foundation.layout.PaddingValues
25
28
import androidx.compose.foundation.layout.Row
26
29
import androidx.compose.foundation.layout.Spacer
27
30
import androidx.compose.foundation.layout.WindowInsets
28
31
import androidx.compose.foundation.layout.WindowInsetsSides
29
- import androidx.compose.foundation.layout.aspectRatio
30
32
import androidx.compose.foundation.layout.fillMaxSize
31
33
import androidx.compose.foundation.layout.fillMaxWidth
32
34
import androidx.compose.foundation.layout.height
@@ -40,8 +42,10 @@ import androidx.compose.foundation.layout.width
40
42
import androidx.compose.foundation.layout.windowInsetsPadding
41
43
import androidx.compose.foundation.layout.windowInsetsTopHeight
42
44
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
45
49
import androidx.compose.foundation.shape.RoundedCornerShape
46
50
import androidx.compose.material.icons.Icons
47
51
import androidx.compose.material.icons.filled.AccountCircle
@@ -58,12 +62,20 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
58
62
import androidx.compose.material3.Text
59
63
import androidx.compose.material3.TopAppBar
60
64
import androidx.compose.runtime.Composable
65
+ import androidx.compose.runtime.LaunchedEffect
61
66
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
62
71
import androidx.compose.ui.Alignment
63
72
import androidx.compose.ui.Modifier
64
73
import androidx.compose.ui.draw.clip
65
74
import androidx.compose.ui.graphics.Color
66
75
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
67
79
import androidx.compose.ui.res.painterResource
68
80
import androidx.compose.ui.res.stringResource
69
81
import androidx.compose.ui.text.style.TextOverflow
@@ -75,20 +87,21 @@ import coil.compose.AsyncImage
75
87
import com.example.jetcaster.R
76
88
import com.example.jetcaster.core.data.database.model.Category
77
89
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
90
+ import com.example.jetcaster.core.data.database.model.Podcast
78
91
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
79
92
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
80
93
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
81
- import com.example.jetcaster.designsystem.theme.Keyline1
82
94
import com.example.jetcaster.ui.home.discover.discoverItems
83
95
import com.example.jetcaster.ui.home.library.libraryItems
84
96
import com.example.jetcaster.ui.theme.JetcasterTheme
85
97
import com.example.jetcaster.util.ToggleFollowPodcastIconButton
86
98
import com.example.jetcaster.util.quantityStringResource
87
99
import com.example.jetcaster.util.verticalGradientScrim
100
+ import kotlinx.collections.immutable.PersistentList
101
+ import kotlinx.coroutines.launch
88
102
import java.time.Duration
89
103
import java.time.LocalDateTime
90
104
import java.time.OffsetDateTime
91
- import kotlinx.collections.immutable.PersistentList
92
105
93
106
@Composable
94
107
fun Home (
@@ -110,6 +123,7 @@ fun Home(
110
123
onPodcastUnfollowed = viewModel::onPodcastUnfollowed,
111
124
navigateToPlayer = navigateToPlayer,
112
125
onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed,
126
+ onLibraryPodcastSelected = viewModel::onLibraryPodcastSelected,
113
127
modifier = Modifier .fillMaxSize()
114
128
)
115
129
}
@@ -174,7 +188,15 @@ fun Home(
174
188
onCategorySelected : (Category ) -> Unit ,
175
189
navigateToPlayer : (String ) -> Unit ,
176
190
onTogglePodcastFollowed : (String ) -> Unit ,
191
+ onLibraryPodcastSelected : (Podcast ? ) -> Unit
177
192
) {
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
+
178
200
Column (
179
201
modifier = modifier.windowInsetsPadding(
180
202
WindowInsets .systemBars.only(WindowInsetsSides .Horizontal )
@@ -221,7 +243,8 @@ fun Home(
221
243
onHomeCategorySelected = onHomeCategorySelected,
222
244
onCategorySelected = onCategorySelected,
223
245
navigateToPlayer = navigateToPlayer,
224
- onTogglePodcastFollowed = onTogglePodcastFollowed
246
+ onTogglePodcastFollowed = onTogglePodcastFollowed,
247
+ onLibraryPodcastSelected = onLibraryPodcastSelected
225
248
)
226
249
}
227
250
}
@@ -243,11 +266,18 @@ private fun HomeContent(
243
266
onCategorySelected : (Category ) -> Unit ,
244
267
navigateToPlayer : (String ) -> Unit ,
245
268
onTogglePodcastFollowed : (String ) -> Unit ,
269
+ onLibraryPodcastSelected : (Podcast ? ) -> Unit
246
270
) {
271
+ val pagerState = rememberPagerState { featuredPodcasts.size }
272
+ LaunchedEffect (pagerState.currentPage, featuredPodcasts) {
273
+ val podcast = featuredPodcasts.getOrNull(pagerState.currentPage)
274
+ onLibraryPodcastSelected(podcast?.podcast)
275
+ }
247
276
LazyColumn (modifier = modifier.fillMaxSize()) {
248
277
if (featuredPodcasts.isNotEmpty()) {
249
278
item {
250
279
FollowedPodcastItem (
280
+ pagerState = pagerState,
251
281
items = featuredPodcasts,
252
282
onPodcastUnfollowed = onPodcastUnfollowed,
253
283
modifier = Modifier
@@ -265,7 +295,7 @@ private fun HomeContent(
265
295
// TODO show a progress indicator or similar
266
296
}
267
297
268
- if (homeCategories.isNotEmpty()) {
298
+ if (featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()) {
269
299
stickyHeader {
270
300
HomeCategoryTabs (
271
301
categories = homeCategories,
@@ -298,6 +328,7 @@ private fun HomeContent(
298
328
299
329
@Composable
300
330
private fun FollowedPodcastItem (
331
+ pagerState : PagerState ,
301
332
items : PersistentList <PodcastWithExtraInfo >,
302
333
onPodcastUnfollowed : (String ) -> Unit ,
303
334
modifier : Modifier = Modifier ,
@@ -306,11 +337,10 @@ private fun FollowedPodcastItem(
306
337
Spacer (Modifier .height(16 .dp))
307
338
308
339
FollowedPodcasts (
340
+ pagerState = pagerState,
309
341
items = items,
310
342
onPodcastUnfollowed = onPodcastUnfollowed,
311
- modifier = Modifier
312
- .fillMaxWidth()
313
- .height(200 .dp)
343
+ modifier = Modifier .fillMaxWidth()
314
344
)
315
345
316
346
Spacer (Modifier .height(16 .dp))
@@ -367,34 +397,54 @@ fun HomeCategoryTabIndicator(
367
397
)
368
398
}
369
399
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 )
370
404
@Composable
371
405
fun FollowedPodcasts (
406
+ pagerState : PagerState ,
372
407
items : PersistentList <PodcastWithExtraInfo >,
373
408
modifier : Modifier = Modifier ,
374
409
onPodcastUnfollowed : (String ) -> Unit ,
375
410
) {
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
+ },
380
428
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
+ }
384
447
)
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
- }
398
448
}
399
449
}
400
450
@@ -409,9 +459,9 @@ private fun FollowedPodcastCarouselItem(
409
459
Column (modifier) {
410
460
Box (
411
461
Modifier
412
- .weight(1f )
462
+ .height(FEATURED_PODCAST_IMAGE_HEIGHT_DP )
463
+ .width(FEATURED_PODCAST_IMAGE_WIDTH_DP )
413
464
.align(Alignment .CenterHorizontally )
414
- .aspectRatio(1f )
415
465
) {
416
466
if (podcastImageUrl != null ) {
417
467
AsyncImage (
@@ -484,7 +534,8 @@ fun PreviewHomeContent() {
484
534
onPodcastUnfollowed = {},
485
535
navigateToPlayer = {},
486
536
onHomeCategorySelected = {},
487
- onTogglePodcastFollowed = {}
537
+ onTogglePodcastFollowed = {},
538
+ onLibraryPodcastSelected = {}
488
539
)
489
540
}
490
541
}
0 commit comments