From e7e54062bc66335c5ea8b06d9280e0f0fed3c2b1 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 18 Mar 2024 16:48:30 -0700 Subject: [PATCH 01/10] [Jetcaster] Migrate from M2 to M3. --- Jetcaster/app/build.gradle.kts | 3 +- .../com/example/jetcaster/ui/JetcasterApp.kt | 6 +- .../com/example/jetcaster/ui/MainActivity.kt | 4 +- .../com/example/jetcaster/ui/home/Home.kt | 732 ++++++++-------- .../ui/home/category/PodcastCategory.kt | 492 ++++++----- .../jetcaster/ui/home/discover/Discover.kt | 68 +- .../jetcaster/ui/player/PlayerScreen.kt | 800 +++++++++--------- .../com/example/jetcaster/ui/theme/Color.kt | 48 +- .../com/example/jetcaster/ui/theme/Theme.kt | 27 +- .../com/example/jetcaster/util/Buttons.kt | 20 +- .../example/jetcaster/util/DynamicTheming.kt | 185 ---- .../example/jetcaster/util/GradientScrim.kt | 2 - .../java/com/example/jetcaster/util/Pager.kt | 21 - Jetcaster/app/src/main/res/values/strings.xml | 1 + Jetcaster/designsystem/build.gradle.kts | 2 + .../jetcaster/designsystem}/theme/Keylines.kt | 2 +- .../jetcaster/designsystem}/theme/Shape.kt | 4 +- .../jetcaster/designsystem}/theme/Type.kt | 96 +-- Jetcaster/gradle/libs.versions.toml | 2 +- 19 files changed, 1084 insertions(+), 1431 deletions(-) delete mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt delete mode 100644 Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt rename Jetcaster/{app/src/main/java/com/example/jetcaster/ui => designsystem/src/main/java/com/example/jetcaster/designsystem}/theme/Keylines.kt (93%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/ui => designsystem/src/main/java/com/example/jetcaster/designsystem}/theme/Shape.kt (90%) rename Jetcaster/{app/src/main/java/com/example/jetcaster/ui => designsystem/src/main/java/com/example/jetcaster/designsystem}/theme/Type.kt (57%) diff --git a/Jetcaster/app/build.gradle.kts b/Jetcaster/app/build.gradle.kts index 46d0d4c8b4..e4fe8c86e8 100644 --- a/Jetcaster/app/build.gradle.kts +++ b/Jetcaster/app/build.gradle.kts @@ -100,9 +100,8 @@ dependencies { implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.materialWindow) + implementation(libs.androidx.compose.material3.window) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 4a49efdf09..838e9eb71b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -16,9 +16,9 @@ package com.example.jetcaster.ui -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index 3c18739094..f6d5bb683d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -16,10 +16,8 @@ package com.example.jetcaster.ui -import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi @@ -34,7 +32,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge( // This app is only ever in dark mode, so hard code detectDarkMode to true. - SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) + //SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) ) setContent { 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 b7db0741ad..7ee947f27d 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 @@ -21,6 +21,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box 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 @@ -35,31 +36,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars +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.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabPosition -import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.TabRow +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.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -78,458 +76,426 @@ 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.PodcastWithExtraInfo +import com.example.jetcaster.designsystem.theme.Keyline1 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.home.library.libraryItems import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.Keyline1 -import com.example.jetcaster.ui.theme.MinContrastOfPrimaryVsSurface -import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import com.example.jetcaster.util.contrastAgainst import com.example.jetcaster.util.quantityStringResource -import com.example.jetcaster.util.rememberDominantColorState import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList @Composable fun Home( - navigateToPlayer: (String) -> Unit, - viewModel: HomeViewModel = viewModel() + navigateToPlayer: (String) -> Unit, + viewModel: HomeViewModel = viewModel() ) { - val viewState by viewModel.state.collectAsStateWithLifecycle() - Surface(Modifier.fillMaxSize()) { - Home( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - discoverViewState = viewState.discoverViewState, - podcastCategoryViewState = viewState.podcastCategoryViewState, - libraryEpisodes = viewState.libraryEpisodes, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - modifier = Modifier.fillMaxSize() - ) - } + val viewState by viewModel.state.collectAsStateWithLifecycle() + Surface(Modifier.fillMaxSize()) { + Home( + featuredPodcasts = viewState.featuredPodcasts, + isRefreshing = viewState.refreshing, + homeCategories = viewState.homeCategories, + selectedHomeCategory = viewState.selectedHomeCategory, + discoverViewState = viewState.discoverViewState, + podcastCategoryViewState = viewState.podcastCategoryViewState, + libraryEpisodes = viewState.libraryEpisodes, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, + onPodcastUnfollowed = viewModel::onPodcastUnfollowed, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + modifier = Modifier.fillMaxSize() + ) + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeAppBar( - backgroundColor: Color, - modifier: Modifier = Modifier + backgroundColor: Color, + modifier: Modifier = Modifier ) { - TopAppBar( - title = { - Row { - Image( - painter = painterResource(R.drawable.ic_logo), - contentDescription = null - ) - Icon( - painter = painterResource(R.drawable.ic_text_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier - .padding(start = 4.dp) - .heightIn(max = 24.dp) - ) - } - }, - backgroundColor = backgroundColor, - actions = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - IconButton( - onClick = { /* TODO: Open search */ } - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.cd_search) - ) - } - IconButton( - onClick = { /* TODO: Open account? */ } - ) { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = stringResource(R.string.cd_account) - ) - } - } - }, - modifier = modifier - ) + TopAppBar( + title = { + Row { + Image( + painter = painterResource(R.drawable.ic_logo), + contentDescription = null + ) + Icon( + painter = painterResource(R.drawable.ic_text_logo), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier + .padding(start = 4.dp) + .heightIn(max = 24.dp) + ) + } + }, + actions = { + IconButton( + onClick = { /* TODO: Open search */ } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + IconButton( + onClick = { /* TODO: Open account? */ } + ) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) + ) + } + }, + modifier = modifier.background(backgroundColor) + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun Home( - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, + libraryEpisodes: List, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (String) -> Unit, + onHomeCategorySelected: (HomeCategory) -> Unit, + onCategorySelected: (Category) -> Unit, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - Column( - modifier = modifier.windowInsetsPadding( - WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) - ) - ) { - // We dynamically theme this sub-section of the layout to match the selected - // 'top podcast' - - val surfaceColor = MaterialTheme.colors.surface - val appBarColor = surfaceColor.copy(alpha = 0.87f) - val dominantColorState = rememberDominantColorState { color -> - // We want a color which has sufficient contrast against the surface color - color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface - } - - DynamicThemePrimaryColorsFromImage(dominantColorState) { - val pagerState = rememberPagerState { featuredPodcasts.size } - - val selectedImageUrl = featuredPodcasts.getOrNull(pagerState.currentPage) - ?.podcast?.imageUrl - - // When the selected image url changes, call updateColorsFromImageUrl() or reset() - LaunchedEffect(selectedImageUrl) { - if (selectedImageUrl != null) { - dominantColorState.updateColorsFromImageUrl(selectedImageUrl) - } else { - dominantColorState.reset() - } - } + Column( + modifier = modifier.windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) + ) + ) { + // We dynamically theme this sub-section of the layout to match the selected + // 'top podcast' - val scrimColor = MaterialTheme.colors.primary.copy(alpha = 0.38f) + val surfaceColor = MaterialTheme.colorScheme.surface + val appBarColor = surfaceColor.copy(alpha = 0.87f) - // Top Bar - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = scrimColor) - ) { - // Draw a scrim over the status bar which matches the app bar - Spacer( - Modifier - .background(appBarColor) - .fillMaxWidth() - .windowInsetsTopHeight(WindowInsets.statusBars) - ) - HomeAppBar( - backgroundColor = appBarColor, - modifier = Modifier.fillMaxWidth() - ) - } + val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) - // Main Content - HomeContent( - featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, - selectedHomeCategory = selectedHomeCategory, - homeCategories = homeCategories, - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - libraryEpisodes = libraryEpisodes, - scrimColor = scrimColor, - pagerState = pagerState, - onPodcastUnfollowed = onPodcastUnfollowed, - onHomeCategorySelected = onHomeCategorySelected, - onCategorySelected = onCategorySelected, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed - ) - } + // Top Bar + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = scrimColor) + ) { + // Draw a scrim over the status bar which matches the app bar + Spacer( + Modifier + .background(appBarColor) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.statusBars) + ) + HomeAppBar( + backgroundColor = appBarColor, + modifier = Modifier.fillMaxWidth() + ) } + + // Main Content + HomeContent( + featuredPodcasts = featuredPodcasts, + isRefreshing = isRefreshing, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + libraryEpisodes = libraryEpisodes, + scrimColor = scrimColor, + onPodcastUnfollowed = onPodcastUnfollowed, + onHomeCategorySelected = onHomeCategorySelected, + onCategorySelected = onCategorySelected, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContent( - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, - scrimColor: Color, - pagerState: PagerState, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, + libraryEpisodes: List, + scrimColor: Color, + 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 - ) - ) - } - } - - if (isRefreshing) { - // TODO show a progress indicator or similar - } + LazyColumn(modifier = modifier.fillMaxSize()) { + if (featuredPodcasts.isNotEmpty()) { + item { + FollowedPodcastItem( + items = featuredPodcasts, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .fillMaxWidth() + .verticalGradientScrim( + color = scrimColor, + startYPercentage = 1f, + endYPercentage = 0f + ) + ) + } + } - if (homeCategories.isNotEmpty()) { - stickyHeader { - HomeCategoryTabs( - categories = homeCategories, - selectedCategory = selectedHomeCategory, - onCategorySelected = onHomeCategorySelected - ) - } - } + if (isRefreshing) { + // TODO show a progress indicator or similar + } - when (selectedHomeCategory) { - HomeCategory.Library -> { - libraryItems( - episodes = libraryEpisodes, - navigateToPlayer = navigateToPlayer - ) - } + if (homeCategories.isNotEmpty()) { + stickyHeader { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + onCategorySelected = onHomeCategorySelected + ) + } + } - HomeCategory.Discover -> { - discoverItems( - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - navigateToPlayer = navigateToPlayer, - onCategorySelected = onCategorySelected, - onTogglePodcastFollowed = onTogglePodcastFollowed - ) - } - } + when (selectedHomeCategory) { + HomeCategory.Library -> { + libraryItems( + episodes = libraryEpisodes, + navigateToPlayer = navigateToPlayer + ) + } + + HomeCategory.Discover -> { + discoverItems( + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + navigateToPlayer = navigateToPlayer, + onCategorySelected = onCategorySelected, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } } + } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun FollowedPodcastItem( - items: PersistentList, - pagerState: PagerState, - onPodcastUnfollowed: (String) -> Unit, - modifier: Modifier = Modifier, + items: PersistentList, + 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) - ) + Column(modifier = modifier) { + Spacer(Modifier.height(16.dp)) + + FollowedPodcasts( + items = items, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) - Spacer(Modifier.height(16.dp)) - } + Spacer(Modifier.height(16.dp)) + } } @Composable private fun HomeCategoryTabs( - categories: List, - selectedCategory: HomeCategory, - onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier + categories: List, + selectedCategory: HomeCategory, + onCategorySelected: (HomeCategory) -> Unit, + modifier: Modifier = Modifier ) { - val selectedIndex = categories.indexOfFirst { it == selectedCategory } - val indicator = @Composable { tabPositions: List -> - HomeCategoryTabIndicator( - Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) - ) - } - - TabRow( - selectedTabIndex = selectedIndex, - indicator = indicator, - modifier = modifier - ) { - categories.forEachIndexed { index, category -> - Tab( - selected = index == selectedIndex, - onClick = { onCategorySelected(category) }, - text = { - Text( - text = when (category) { - HomeCategory.Library -> stringResource(R.string.home_library) - HomeCategory.Discover -> stringResource(R.string.home_discover) - }, - style = MaterialTheme.typography.body2 - ) - } - ) + val selectedIndex = categories.indexOfFirst { it == selectedCategory } + val indicator = @Composable { tabPositions: List -> + HomeCategoryTabIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) + ) + } + + TabRow( + selectedTabIndex = selectedIndex, + indicator = indicator, + modifier = modifier + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = index == selectedIndex, + onClick = { onCategorySelected(category) }, + text = { + Text( + text = when (category) { + HomeCategory.Library -> stringResource(R.string.home_library) + HomeCategory.Discover -> stringResource(R.string.home_discover) + }, + style = MaterialTheme.typography.bodyMedium + ) } + ) } + } } @Composable fun HomeCategoryTabIndicator( - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.onSurface + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface ) { - Spacer( - modifier - .padding(horizontal = 24.dp) - .height(4.dp) - .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) - ) + Spacer( + modifier + .padding(horizontal = 24.dp) + .height(4.dp) + .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) + ) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun FollowedPodcasts( - items: PersistentList, - pagerState: PagerState, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, + items: PersistentList, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (String) -> Unit, ) { - HorizontalPager( - state = pagerState, - modifier = modifier - ) { 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) - .fillMaxSize() - ) + val lastIndex = items.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues( + start = Keyline1, + top = 16.dp, + end = Keyline1, + ) + ) { + itemsIndexed(items) { index: Int, + (podcast, lastEpisodeDate): PodcastWithExtraInfo -> + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, + lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier.padding(4.dp) + ) + + if (index < lastIndex) Spacer(Modifier.width(24.dp)) } + } } @Composable private fun FollowedPodcastCarouselItem( - modifier: Modifier = Modifier, - podcastImageUrl: String? = null, - podcastTitle: String? = null, - lastEpisodeDateText: String? = null, - onUnfollowedClick: () -> Unit, + modifier: Modifier = Modifier, + podcastImageUrl: String? = null, + podcastTitle: String? = null, + lastEpisodeDateText: String? = null, + onUnfollowedClick: () -> Unit, ) { - Column( - modifier.padding(horizontal = 12.dp, vertical = 8.dp) + Column(modifier) { + Box( + Modifier + .weight(1f) + .align(Alignment.CenterHorizontally) + .aspectRatio(1f) ) { - Box( - Modifier - .weight(1f) - .align(Alignment.CenterHorizontally) - .aspectRatio(1f) - ) { - if (podcastImageUrl != null) { - AsyncImage( - model = podcastImageUrl, - contentDescription = podcastTitle, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } + if (podcastImageUrl != null) { + AsyncImage( + model = podcastImageUrl, + contentDescription = podcastTitle, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + ) + } - ToggleFollowPodcastIconButton( - onClick = onUnfollowedClick, - isFollowed = true, /* All podcasts are followed in this feed */ - modifier = Modifier.align(Alignment.BottomEnd) - ) - } + ToggleFollowPodcastIconButton( + onClick = onUnfollowedClick, + isFollowed = true, /* All podcasts are followed in this feed */ + modifier = Modifier.align(Alignment.BottomEnd) + ) + } - if (lastEpisodeDateText != null) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = lastEpisodeDateText, - style = MaterialTheme.typography.caption, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .align(Alignment.CenterHorizontally) - ) - } - } + if (lastEpisodeDateText != null) { + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) } + } } @Composable private fun lastUpdated(updated: OffsetDateTime): String { - val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) - val days = duration.toDays().toInt() - - return when { - days > 28 -> stringResource(R.string.updated_longer) - days >= 7 -> { - val weeks = days / 7 - quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) - } - - days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) - else -> stringResource(R.string.updated_today) + val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) + val days = duration.toDays().toInt() + + return when { + days > 28 -> stringResource(R.string.updated_longer) + days >= 7 -> { + val weeks = days / 7 + quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) } + + days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) + else -> stringResource(R.string.updated_today) + } } @Composable @Preview fun PreviewHomeContent() { - JetcasterTheme { - Home( - featuredPodcasts = PreviewPodcastsWithExtraInfo, - isRefreshing = false, - homeCategories = HomeCategory.entries, - selectedHomeCategory = HomeCategory.Discover, - discoverViewState = DiscoverViewState( - categories = PreviewCategories, - selectedCategory = PreviewCategories.first(), - ), - podcastCategoryViewState = PodcastCategoryViewState( - topPodcasts = PreviewPodcastsWithExtraInfo, - episodes = PreviewEpisodeToPodcasts, - ), - libraryEpisodes = emptyList(), - onCategorySelected = {}, - onPodcastUnfollowed = {}, - navigateToPlayer = {}, - onHomeCategorySelected = {}, - onTogglePodcastFollowed = {} - ) - } + JetcasterTheme { + Home( + featuredPodcasts = PreviewPodcastsWithExtraInfo, + isRefreshing = false, + homeCategories = HomeCategory.entries, + selectedHomeCategory = HomeCategory.Discover, + discoverViewState = DiscoverViewState( + categories = PreviewCategories, + selectedCategory = PreviewCategories.first(), + ), + podcastCategoryViewState = PodcastCategoryViewState( + topPodcasts = PreviewPodcastsWithExtraInfo, + episodes = PreviewEpisodeToPodcasts, + ), + libraryEpisodes = emptyList(), + onCategorySelected = {}, + onPodcastUnfollowed = {}, + navigateToPlayer = {}, + onHomeCategorySelected = {}, + onTogglePodcastFollowed = {} + ) + } } @Composable @Preview fun PreviewPodcastCard() { - JetcasterTheme { - FollowedPodcastCarouselItem( - modifier = Modifier.size(128.dp), - onUnfollowedClick = {} - ) - } + JetcasterTheme { + FollowedPodcastCarouselItem( + modifier = Modifier.size(128.dp), + onUnfollowedClick = {} + ) + } } 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 321d72e3f8..093b95b460 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 @@ -33,21 +33,17 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,301 +68,299 @@ import com.example.jetcaster.core.data.database.model.Episode 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.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.PreviewEpisodes 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 java.time.format.DateTimeFormatter import java.time.format.FormatStyle data class PodcastCategoryViewState( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() ) + fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + topPodcasts: List, + episodes: List, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) - } + item { + CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + } - items(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, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + onTogglePodcastFollowed: (String) -> Unit ) { - CategoryPodcastRow( - podcasts = topPodcasts, - onTogglePodcastFollowed = onTogglePodcastFollowed, - modifier = Modifier.fillMaxWidth() - ) + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + modifier = Modifier.fillMaxWidth() + ) } @Composable fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - modifier: Modifier = Modifier + episode: Episode, + podcast: Podcast, + onClick: (String) -> Unit, + modifier: Modifier = Modifier ) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() + ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { + val ( + divider, episodeTitle, podcastTitle, image, playIcon, + date, addPlaylist, overflow + ) = createRefs() - Divider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) + HorizontalDivider( + Modifier.constrainAs(divider) { + top.linkTo(parent.top) + centerHorizontallyTo(parent) + width = fillToConstraints + } + ) - width = fillToConstraints - } - ) + // If we have an image Url, we can show it using Coil + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + .constrainAs(image) { + end.linkTo(parent.end, 16.dp) + top.linkTo(parent.top, 16.dp) + }, + ) - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - }, + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.constrainAs(episodeTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f ) + top.linkTo(parent.top, 16.dp) + height = preferredWrapContent + width = preferredWrapContent + } + ) - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(parent.top, 16.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) + val titleImageBarrier = createBottomBarrier(podcastTitle, image) - val titleImageBarrier = createBottomBarrier(podcastTitle, image) + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.constrainAs(podcastTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(episodeTitle.bottom, 6.dp) + height = preferredWrapContent + width = preferredWrapContent + } + ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + .constrainAs(playIcon) { + start.linkTo(parent.start, Keyline1) + top.linkTo(titleImageBarrier, margin = 10.dp) + bottom.linkTo(parent.bottom, 10.dp) } + ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp) - ) { /* TODO */ } - .size(48.dp) - .padding(6.dp) - .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.constrainAs(date) { + centerVerticallyTo(playIcon) + linkTo( + start = playIcon.end, + startMargin = 12.dp, + end = addPlaylist.start, + endMargin = 16.dp, + bias = 0f // float this towards the start ) + width = preferredWrapContent + } + ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - val duration = episode.duration - Text( - text = when { - duration != null -> { - // If we have the duration, we combine the date/duration via a - // formatted string - stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(episode.published), - duration.toMinutes().toInt() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.caption, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start - ) - width = preferredWrapContent - } - ) - - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add) - ) - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(addPlaylist) { + end.linkTo(overflow.start) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more) - ) - } - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(overflow) { + end.linkTo(parent.end, 8.dp) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } } @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, - modifier: Modifier = Modifier + podcasts: List, + onTogglePodcastFollowed: (String) -> Unit, + modifier: Modifier = Modifier ) { - val lastIndex = podcasts.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) - ) { - itemsIndexed(items = podcasts) { index: Int, - (podcast, _, isFollowed): PodcastWithExtraInfo -> - TopPodcastRowItem( - podcastTitle = podcast.title, - podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.width(128.dp) - ) + val lastIndex = podcasts.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) + ) { + itemsIndexed(items = podcasts) { index: Int, + (podcast, _, isFollowed): PodcastWithExtraInfo -> + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = isFollowed, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, + modifier = Modifier.width(128.dp) + ) - if (index < lastIndex) Spacer(Modifier.width(24.dp)) - } + if (index < lastIndex) Spacer(Modifier.width(24.dp)) } + } } @Composable private fun TopPodcastRowItem( - podcastTitle: String, - isFollowed: Boolean, - modifier: Modifier = Modifier, - onToggleFollowClicked: () -> Unit, - podcastImageUrl: String? = null, + podcastTitle: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, + podcastImageUrl: String? = null, ) { - Column( - modifier.semantics(mergeDescendants = true) {} + Column( + modifier.semantics(mergeDescendants = true) {} + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) - ) { - if (podcastImageUrl != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } - - ToggleFollowPodcastIconButton( - onClick = onToggleFollowClicked, - isFollowed = isFollowed, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - - Text( - text = podcastTitle, - style = MaterialTheme.typography.body2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() + if (podcastImageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), ) + } + + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.BottomEnd) + ) } + + Text( + text = podcastTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } } private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @Preview @Composable fun PreviewEpisodeListItem() { - JetcasterTheme { - EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], - onClick = { }, - modifier = Modifier.fillMaxWidth() - ) - } + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + modifier = Modifier.fillMaxWidth() + ) + } } 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 817d620b70..e131d59f31 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,24 +16,31 @@ package com.example.jetcaster.ui.home.discover +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 -import androidx.compose.material.Tab -import androidx.compose.material.TabPosition -import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.PodcastCategoryViewState import com.example.jetcaster.ui.home.category.podcastCategory -import com.example.jetcaster.ui.theme.Keyline1 data class DiscoverViewState( val categories: List = emptyList(), @@ -84,11 +91,11 @@ private fun PodcastCategoryTabs( ) { val selectedIndex = categories.indexOfFirst { it == selectedCategory } ScrollableTabRow( - selectedTabIndex = selectedIndex, - divider = {}, /* Disable the built-in divider */ - edgePadding = Keyline1, - indicator = emptyTabIndicator, - modifier = modifier + selectedTabIndex = selectedIndex, + divider = {}, /* Disable the built-in divider */ + edgePadding = Keyline1, + indicator = emptyTabIndicator, + modifier = modifier ) { categories.forEachIndexed { index, category -> Tab( @@ -113,20 +120,37 @@ private fun ChoiceChipContent( ) { Surface( color = when { - selected -> MaterialTheme.colors.primary.copy(alpha = 0.08f) - else -> MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + selected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainer }, contentColor = when { - selected -> MaterialTheme.colors.primary - else -> MaterialTheme.colors.onSurface + selected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant }, - shape = MaterialTheme.shapes.small, + shape = MaterialTheme.shapes.medium, modifier = modifier ) { - Text( - text = text, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + horizontal = when { + selected -> 8.dp + else -> 16.dp + }, + vertical = 8.dp + ) + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.cd_selected_category), + modifier = Modifier.height(18.dp).padding(end = 8.dp) + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index f5a3ae922b..8b3bcd7dfe 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -41,31 +41,27 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Slider -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -89,13 +85,9 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.MinContrastOfPrimaryVsSurface -import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage -import com.example.jetcaster.util.contrastAgainst import com.example.jetcaster.util.isBookPosture import com.example.jetcaster.util.isSeparatingPosture import com.example.jetcaster.util.isTableTopPosture -import com.example.jetcaster.util.rememberDominantColorState import com.example.jetcaster.util.verticalGradientScrim import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane @@ -107,13 +99,13 @@ import java.time.Duration */ @Composable fun PlayerScreen( - viewModel: PlayerViewModel, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit + viewModel: PlayerViewModel, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit ) { - val uiState = viewModel.uiState - PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) + val uiState = viewModel.uiState + PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) } /** @@ -121,91 +113,89 @@ fun PlayerScreen( */ @Composable private fun PlayerScreen( - uiState: PlayerUiState, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - Surface(modifier) { - if (uiState.podcastName.isNotEmpty()) { - PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) - } else { - FullScreenLoading() - } + Surface(modifier) { + if (uiState.podcastName.isNotEmpty()) { + PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) + } else { + FullScreenLoading() } + } } @Composable fun PlayerContent( - uiState: PlayerUiState, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - PlayerDynamicTheme(uiState.podcastImageUrl) { - val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() - // Use a two pane layout if there is a fold impacting layout (meaning it is separating - // or non-flat) or if we have a large enough width to show both. - if ( - windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || - isBookPosture(foldingFeature) || - isTableTopPosture(foldingFeature) || - isSeparatingPosture(foldingFeature) - ) { - // Determine if we are going to be using a vertical strategy (as if laying out - // both sides in a column). We want to do so if we are in a tabletop posture, - // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. - val usingVerticalStrategy = - isTableTopPosture(foldingFeature) || - ( - isSeparatingPosture(foldingFeature) && - foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL - ) + // Use a two pane layout if there is a fold impacting layout (meaning it is separating + // or non-flat) or if we have a large enough width to show both. + if ( + windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || + isBookPosture(foldingFeature) || + isTableTopPosture(foldingFeature) || + isSeparatingPosture(foldingFeature) + ) { + // Determine if we are going to be using a vertical strategy (as if laying out + // both sides in a column). We want to do so if we are in a tabletop posture, + // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. + val usingVerticalStrategy = + isTableTopPosture(foldingFeature) || + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL + ) - if (usingVerticalStrategy) { - TwoPane( - first = { - PlayerContentTableTopTop(uiState = uiState) - }, - second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) - }, - strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures, - modifier = modifier, - ) - } else { - Column( - modifier = modifier - .fillMaxSize() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .systemBarsPadding() - .padding(horizontal = 8.dp) - ) { - TopAppBar(onBackPress = onBackPress) - TwoPane( - first = { - PlayerContentBookStart(uiState = uiState) - }, - second = { - PlayerContentBookEnd(uiState = uiState) - }, - strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures - ) - } - } - } else { - PlayerContentRegular(uiState, onBackPress, modifier) - } + if (usingVerticalStrategy) { + TwoPane( + first = { + PlayerContentTableTopTop(uiState = uiState) + }, + second = { + PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + }, + strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + modifier = modifier, + ) + } else { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar(onBackPress = onBackPress) + TwoPane( + first = { + PlayerContentBookStart(uiState = uiState) + }, + second = { + PlayerContentBookEnd(uiState = uiState) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures + ) + } } + } else { + PlayerContentRegular(uiState, onBackPress, modifier) + } } /** @@ -213,44 +203,44 @@ fun PlayerContent( */ @Composable private fun PlayerContentRegular( - uiState: PlayerUiState, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar(onBackPress = onBackPress) Column( - modifier = modifier - .fillMaxSize() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .systemBarsPadding() - .padding(horizontal = 8.dp) + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, - modifier = Modifier.weight(10f) - ) - Spacer(modifier = Modifier.height(32.dp)) - PodcastDescription(uiState.title, uiState.podcastName) - Spacer(modifier = Modifier.height(32.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(10f) - ) { - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) - } - Spacer(modifier = Modifier.weight(1f)) - } + Spacer(modifier = Modifier.weight(1f)) + PlayerImage( + podcastImageUrl = uiState.podcastImageUrl, + modifier = Modifier.weight(10f) + ) + Spacer(modifier = Modifier.height(32.dp)) + PodcastDescription(uiState.title, uiState.podcastName) + Spacer(modifier = Modifier.height(32.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerSlider(uiState.duration) + PlayerButtons(Modifier.padding(vertical = 8.dp)) + } + Spacer(modifier = Modifier.weight(1f)) } + } } /** @@ -258,28 +248,28 @@ private fun PlayerContentRegular( */ @Composable private fun PlayerContentTableTopTop( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - // Content for the top part of the screen - Column( - modifier = modifier - .fillMaxWidth() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Top - ) + // Content for the top part of the screen + Column( + modifier = modifier + .fillMaxWidth() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Top ) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - PlayerImage(uiState.podcastImageUrl) - } + ) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PlayerImage(uiState.podcastImageUrl) + } } /** @@ -287,36 +277,36 @@ private fun PlayerContentTableTopTop( */ @Composable private fun PlayerContentTableTopBottom( - uiState: PlayerUiState, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - // Content for the table part of the screen - Column( - modifier = modifier - .windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom - ) + // Content for the table part of the screen + Column( + modifier = modifier + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom ) - .padding(horizontal = 32.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TopAppBar(onBackPress = onBackPress) - PodcastDescription( - title = uiState.title, - podcastName = uiState.podcastName, - titleTextStyle = MaterialTheme.typography.h6 ) - Spacer(modifier = Modifier.weight(0.5f)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(10f) - ) { - PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) - PlayerSlider(uiState.duration) - } + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TopAppBar(onBackPress = onBackPress) + PodcastDescription( + title = uiState.title, + podcastName = uiState.podcastName, + titleTextStyle = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.weight(0.5f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) + PlayerSlider(uiState.duration) } + } } /** @@ -324,28 +314,28 @@ private fun PlayerContentTableTopBottom( */ @Composable private fun PlayerContentBookStart( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - vertical = 8.dp, - horizontal = 16.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround - ) { - Spacer(modifier = Modifier.height(32.dp)) - PodcastInformation( - uiState.title, - uiState.podcastName, - uiState.summary - ) - Spacer(modifier = Modifier.height(32.dp)) - } + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + vertical = 8.dp, + horizontal = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround + ) { + Spacer(modifier = Modifier.height(32.dp)) + PodcastInformation( + uiState.title, + uiState.podcastName, + uiState.summary + ) + Spacer(modifier = Modifier.height(32.dp)) + } } /** @@ -353,254 +343,250 @@ private fun PlayerContentBookStart( */ @Composable private fun PlayerContentBookEnd( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround, - ) { - PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, - modifier = Modifier - .padding(vertical = 16.dp) - .weight(1f) - ) - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) - } + Column( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + ) { + PlayerImage( + podcastImageUrl = uiState.podcastImageUrl, + modifier = Modifier + .padding(vertical = 16.dp) + .weight(1f) + ) + PlayerSlider(uiState.duration) + PlayerButtons(Modifier.padding(vertical = 8.dp)) + } } @Composable private fun TopAppBar(onBackPress: () -> Unit) { - Row(Modifier.fillMaxWidth()) { - IconButton(onClick = onBackPress) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.cd_back) - ) - } - Spacer(Modifier.weight(1f)) - IconButton(onClick = { /* TODO */ }) { - Icon( - imageVector = Icons.Default.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add) - ) - } - IconButton(onClick = { /* TODO */ }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more) - ) - } + Row(Modifier.fillMaxWidth()) { + IconButton(onClick = onBackPress) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) } + Spacer(Modifier.weight(1f)) + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } + } } @Composable private fun PlayerImage( - podcastImageUrl: String, - modifier: Modifier = Modifier + podcastImageUrl: String, + modifier: Modifier = Modifier ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) - .aspectRatio(1f) - .clip(MaterialTheme.shapes.medium) - ) + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + ) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun PodcastDescription( - title: String, - podcastName: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5 + title: String, + podcastName: String, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall ) { - Text( - text = title, - style = titleTextStyle, - maxLines = 1, - modifier = Modifier.basicMarquee() - ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = podcastName, - style = MaterialTheme.typography.body2, - maxLines = 1 - ) - } + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + modifier = Modifier.basicMarquee() + ) + Text( + text = podcastName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) } @Composable private fun PodcastInformation( - title: String, - name: String, - summary: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5, - nameTextStyle: TextStyle = MaterialTheme.typography.h3, + title: String, + name: String, + summary: String, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Text( - text = name, - style = nameTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = title, - style = titleTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(32.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = summary, - style = MaterialTheme.typography.body2, - ) - } - Spacer(modifier = Modifier.weight(1f)) - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = name, + style = nameTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.weight(1f)) + } } @Composable private fun PlayerSlider(episodeDuration: Duration?) { - if (episodeDuration != null) { - Column(Modifier.fillMaxWidth()) { - Slider(value = 0f, onValueChange = { }) - Row(Modifier.fillMaxWidth()) { - Text(text = "0s") - Spacer(modifier = Modifier.weight(1f)) - Text("${episodeDuration.seconds}s") - } - } + if (episodeDuration != null) { + Column(Modifier.fillMaxWidth()) { + Slider(value = 0f, onValueChange = { }) + Row(Modifier.fillMaxWidth()) { + Text(text = "0s") + Spacer(modifier = Modifier.weight(1f)) + Text("${episodeDuration.seconds}s") + } } + } } @Composable private fun PlayerButtons( - modifier: Modifier = Modifier, - playerButtonSize: Dp = 72.dp, - sideButtonSize: Dp = 48.dp + modifier: Modifier = Modifier, + playerButtonSize: Dp = 72.dp, + sideButtonSize: Dp = 48.dp ) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly - ) { - val buttonsModifier = Modifier - .size(sideButtonSize) - .semantics { role = Role.Button } + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val buttonsModifier = Modifier + .size(sideButtonSize) + .semantics { role = Role.Button } - Image( - imageVector = Icons.Filled.SkipPrevious, - contentDescription = stringResource(R.string.cd_skip_previous), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Filled.Replay10, - contentDescription = stringResource(R.string.cd_reply10), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } - ) - Image( - imageVector = Icons.Filled.Forward30, - contentDescription = stringResource(R.string.cd_forward30), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Filled.SkipNext, - contentDescription = stringResource(R.string.cd_skip_next), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - } + Image( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.cd_skip_previous), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Filled.Replay10, + contentDescription = stringResource(R.string.cd_reply10), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + ) + Image( + imageVector = Icons.Filled.Forward30, + contentDescription = stringResource(R.string.cd_forward30), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.cd_skip_next), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + } } /** * Theme that updates the colors dynamically depending on the podcast image URL */ -@Composable -private fun PlayerDynamicTheme( - podcastImageUrl: String, - content: @Composable () -> Unit -) { - val surfaceColor = MaterialTheme.colors.surface - val dominantColorState = rememberDominantColorState( - defaultColor = MaterialTheme.colors.surface - ) { color -> - // We want a color which has sufficient contrast against the surface color - color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface - } - DynamicThemePrimaryColorsFromImage(dominantColorState) { - // Update the dominantColorState with colors coming from the podcast image URL - LaunchedEffect(podcastImageUrl) { - if (podcastImageUrl.isNotEmpty()) { - dominantColorState.updateColorsFromImageUrl(podcastImageUrl) - } else { - dominantColorState.reset() - } - } - content() - } -} +//@Composable +//private fun PlayerDynamicTheme( +// podcastImageUrl: String, +// content: @Composable () -> Unit +//) { +// val surfaceColor = MaterialTheme.colors.surface +// val dominantColorState = rememberDominantColorState( +// defaultColor = MaterialTheme.colors.surface +// ) { color -> +// // We want a color which has sufficient contrast against the surface color +// color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface +// } +// DynamicThemePrimaryColorsFromImage(dominantColorState) { +// // Update the dominantColorState with colors coming from the podcast image URL +// LaunchedEffect(podcastImageUrl) { +// if (podcastImageUrl.isNotEmpty()) { +// dominantColorState.updateColorsFromImageUrl(podcastImageUrl) +// } else { +// dominantColorState.reset() +// } +// } +// content() +// } +//} /** * Full screen circular progress indicator */ @Composable private fun FullScreenLoading(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) { - CircularProgressIndicator() - } + Box( + modifier = modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + } } @Preview @Composable fun TopAppBarPreview() { - JetcasterTheme { - TopAppBar(onBackPress = { }) - } + JetcasterTheme { + TopAppBar(onBackPress = { }) + } } @Preview @Composable fun PlayerButtonsPreview() { - JetcasterTheme { - PlayerButtons() - } + JetcasterTheme { + PlayerButtons() + } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @@ -610,18 +596,18 @@ fun PlayerButtonsPreview() { @Preview(device = Devices.DESKTOP) @Composable fun PlayerScreenPreview() { - JetcasterTheme { - BoxWithConstraints { - PlayerScreen( - PlayerUiState( - title = "Title", - duration = Duration.ofHours(2), - podcastName = "Podcast" - ), - displayFeatures = emptyList(), - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), - onBackPress = { } - ) - } + JetcasterTheme { + BoxWithConstraints { + PlayerScreen( + PlayerUiState( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast" + ), + displayFeatures = emptyList(), + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), + onBackPress = { } + ) } + } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt index 03254e8269..89f0a683f3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -16,12 +16,6 @@ package com.example.jetcaster.ui.theme -import androidx.compose.material.Colors -import androidx.compose.material.darkColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver - /** * This is the minimum amount of calculated contrast for a color to be used on top of the * surface color. These values are defined within the WCAG AA guidelines, and we use a value of @@ -29,24 +23,24 @@ import androidx.compose.ui.graphics.compositeOver */ const val MinContrastOfPrimaryVsSurface = 3f -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} - -val Yellow800 = Color(0xFFF29F05) -val Red300 = Color(0xFFEA6D7E) - -val JetcasterColors = darkColors( - primary = Yellow800, - onPrimary = Color.Black, - primaryVariant = Yellow800, - secondary = Yellow800, - onSecondary = Color.Black, - error = Red300, - onError = Color.Black -) +///** +// * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the +// * given [alpha]. Useful for situations where semi-transparent colors are undesirable. +// */ +//@Composable +//fun Colors.compositedOnSurface(alpha: Float): Color { +// return onSurface.copy(alpha = alpha).compositeOver(surface) +//} +// +//val Yellow800 = Color(0xFFF29F05) +//val Red300 = Color(0xFFEA6D7E) +// +//val JetcasterColors = darkColors( +// primary = Yellow800, +// onPrimary = Color.Black, +// primaryVariant = Yellow800, +// secondary = Yellow800, +// onSecondary = Color.Black, +// error = Red300, +// onError = Color.Black +//) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt index a6c728a5eb..984ffa9e7f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -19,7 +19,7 @@ package com.example.jetcaster.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import com.example.jetcaster.designsystem.theme.JetcasterShapes +import com.example.jetcaster.designsystem.theme.JetcasterTypography import com.example.jetcaster.designsystem.theme.backgroundDark import com.example.jetcaster.designsystem.theme.backgroundDarkHighContrast import com.example.jetcaster.designsystem.theme.backgroundDarkMediumContrast @@ -243,18 +245,6 @@ import com.example.jetcaster.designsystem.theme.tertiaryLight import com.example.jetcaster.designsystem.theme.tertiaryLightHighContrast import com.example.jetcaster.designsystem.theme.tertiaryLightMediumContrast -@Composable -fun JetcasterTheme( - content: @Composable () -> Unit -) { - MaterialTheme( - colors = JetcasterColors, - typography = JetcasterTypography, - shapes = JetcasterShapes, - content = content - ) -} - private val lightScheme = lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, @@ -496,10 +486,10 @@ val unspecified_scheme = ColorFamily( ) @Composable -fun JetcasterThemeM3( +fun JetcasterTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = when { @@ -515,14 +505,15 @@ fun JetcasterThemeM3( if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() + window.statusBarColor = Color.Transparent.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme } } - androidx.compose.material3.MaterialTheme( + MaterialTheme( colorScheme = colorScheme, - typography = JetcasterTypographyM3, + shapes = JetcasterShapes, + typography = JetcasterTypography, content = content ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt index 2fe99a0c1a..c90ffc9d82 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt @@ -20,18 +20,16 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics @@ -63,8 +61,8 @@ fun ToggleFollowPodcastIconButton( }, tint = animateColorAsState( when { - isFollowed -> LocalContentColor.current - else -> Color.Black.copy(alpha = ContentAlpha.high) + isFollowed -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.primary } ).value, modifier = Modifier @@ -75,11 +73,11 @@ fun ToggleFollowPodcastIconButton( .background( color = animateColorAsState( when { - isFollowed -> MaterialTheme.colors.surface.copy(0.38f) - else -> Color.White + isFollowed -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceContainerHighest } ).value, - shape = MaterialTheme.shapes.small + shape = CircleShape ) .padding(4.dp) ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt deleted file mode 100644 index 4cead93b60..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt +++ /dev/null @@ -1,185 +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.util - -import android.content.Context -import androidx.collection.LruCache -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.core.graphics.drawable.toBitmap -import androidx.palette.graphics.Palette -import coil.imageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.size.Scale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -fun rememberDominantColorState( - context: Context = LocalContext.current, - defaultColor: Color = MaterialTheme.colors.primary, - defaultOnColor: Color = MaterialTheme.colors.onPrimary, - cacheSize: Int = 12, - isColorValid: (Color) -> Boolean = { true } -): DominantColorState = remember { - DominantColorState(context, defaultColor, defaultOnColor, cacheSize, isColorValid) -} - -/** - * A composable which allows dynamic theming of the [androidx.compose.material.Colors.primary] - * color from an image. - */ -@Composable -fun DynamicThemePrimaryColorsFromImage( - dominantColorState: DominantColorState = rememberDominantColorState(), - content: @Composable () -> Unit -) { - val colors = MaterialTheme.colors.copy( - primary = animateColorAsState( - dominantColorState.color, - spring(stiffness = Spring.StiffnessLow) - ).value, - onPrimary = animateColorAsState( - dominantColorState.onColor, - spring(stiffness = Spring.StiffnessLow) - ).value - ) - MaterialTheme(colors = colors, content = content) -} - -/** - * A class which stores and caches the result of any calculated dominant colors - * from images. - * - * @param context Android context - * @param defaultColor The default color, which will be used if [calculateDominantColor] fails to - * calculate a dominant color - * @param defaultOnColor The default foreground 'on color' for [defaultColor]. - * @param cacheSize The size of the [LruCache] used to store recent results. Pass `0` to - * disable the cache. - * @param isColorValid A lambda which allows filtering of the calculated image colors. - */ -@Stable -class DominantColorState( - private val context: Context, - private val defaultColor: Color, - private val defaultOnColor: Color, - cacheSize: Int = 12, - private val isColorValid: (Color) -> Boolean = { true } -) { - var color by mutableStateOf(defaultColor) - private set - var onColor by mutableStateOf(defaultOnColor) - private set - - private val cache = when { - cacheSize > 0 -> LruCache(cacheSize) - else -> null - } - - suspend fun updateColorsFromImageUrl(url: String) { - val result = calculateDominantColor(url) - color = result?.color ?: defaultColor - onColor = result?.onColor ?: defaultOnColor - } - - private suspend fun calculateDominantColor(url: String): DominantColors? { - val cached = cache?.get(url) - if (cached != null) { - // If we already have the result cached, return early now... - return cached - } - - // Otherwise we calculate the swatches in the image, and return the first valid color - return calculateSwatchesInImage(context, url) - // First we want to sort the list by the color's population - .sortedByDescending { swatch -> swatch.population } - // Then we want to find the first valid color - .firstOrNull { swatch -> isColorValid(Color(swatch.rgb)) } - // If we found a valid swatch, wrap it in a [DominantColors] - ?.let { swatch -> - DominantColors( - color = Color(swatch.rgb), - onColor = Color(swatch.bodyTextColor).copy(alpha = 1f) - ) - } - // Cache the resulting [DominantColors] - ?.also { result -> cache?.put(url, result) } - } - - /** - * Reset the color values to [defaultColor]. - */ - fun reset() { - color = defaultColor - onColor = defaultColor - } -} - -@Immutable -private data class DominantColors(val color: Color, val onColor: Color) - -/** - * Fetches the given [imageUrl] with Coil, then uses [Palette] to calculate the dominant color. - */ -private suspend fun calculateSwatchesInImage( - context: Context, - imageUrl: String -): List { - val request = ImageRequest.Builder(context) - .data(imageUrl) - // We scale the image to cover 128px x 128px (i.e. min dimension == 128px) - .size(128).scale(Scale.FILL) - // Disable hardware bitmaps, since Palette uses Bitmap.getPixels() - .allowHardware(false) - // Set a custom memory cache key to avoid overwriting the displayed image in the cache - .memoryCacheKey("$imageUrl.palette") - .build() - - val bitmap = when (val result = context.imageLoader.execute(request)) { - is SuccessResult -> result.drawable.toBitmap() - else -> null - } - - return bitmap?.let { - withContext(Dispatchers.Default) { - val palette = Palette.Builder(bitmap) - // Disable any bitmap resizing in Palette. We've already loaded an appropriately - // sized bitmap through Coil - .resizeBitmapArea(0) - // Clear any built-in filters. We want the unfiltered dominant color - .clearFilters() - // We reduce the maximum color count down to 8 - .maximumColorCount(8) - .generate() - - palette.swatches - } - } ?: emptyList() -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt index 5c6a996361..fb2b8250df 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.util import androidx.annotation.FloatRange -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -113,7 +112,6 @@ private data class VerticalGradientElement( } } -@OptIn(ExperimentalComposeUiApi::class) private class VerticalGradientModifier( var onDraw: DrawScope.() -> Unit ) : Modifier.Node(), DrawModifierNode { diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt deleted file mode 100644 index 1556286ea6..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt +++ /dev/null @@ -1,21 +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.util - -/** - * Pager is now a library! https://google.github.io/accompanist/pager/ - */ diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index c2cd845503..08bb67ca9c 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -54,5 +54,6 @@ Follow Following Not following + Selected category diff --git a/Jetcaster/designsystem/build.gradle.kts b/Jetcaster/designsystem/build.gradle.kts index 566a565b39..ce86e815dc 100644 --- a/Jetcaster/designsystem/build.gradle.kts +++ b/Jetcaster/designsystem/build.gradle.kts @@ -30,6 +30,8 @@ android { dependencies { val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.text) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt similarity index 93% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt rename to Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt index 5242395c1c..e097575da7 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.ui.unit.dp diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt rename to Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt index 0e7b2e1148..78e51c6dd6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val JetcasterShapes = Shapes( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt similarity index 57% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt rename to Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt index 0fcabdd969..b9d6eb171e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt @@ -14,105 +14,13 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme -import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import com.example.jetcaster.designsystem.theme.Montserrat -val JetcasterTypography = Typography( - h1 = TextStyle( - fontFamily = Montserrat, - fontSize = 96.sp, - fontWeight = FontWeight.Light, - lineHeight = 117.sp, - letterSpacing = (-1.5).sp - ), - h2 = TextStyle( - fontFamily = Montserrat, - fontSize = 60.sp, - fontWeight = FontWeight.Light, - lineHeight = 73.sp, - letterSpacing = (-0.5).sp - ), - h3 = TextStyle( - fontFamily = Montserrat, - fontSize = 48.sp, - fontWeight = FontWeight.Normal, - lineHeight = 59.sp - ), - h4 = TextStyle( - fontFamily = Montserrat, - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 37.sp - ), - h5 = TextStyle( - fontFamily = Montserrat, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 29.sp - ), - h6 = TextStyle( - fontFamily = Montserrat, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp - ), - subtitle1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.5.sp - ), - subtitle2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - lineHeight = 17.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - lineHeight = 20.sp, - letterSpacing = 0.15.sp - ), - body2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.25.sp - ), - caption = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 0.sp - ), - overline = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.sp - ) -) - -val JetcasterTypographyM3 = androidx.compose.material3.Typography( +val JetcasterTypography = androidx.compose.material3.Typography( displayLarge = TextStyle( fontFamily = Montserrat, fontSize = 57.sp, diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 402d8fdfb2..5a8dd0fea8 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -72,7 +72,7 @@ androidx-compose-foundation-layout = { module = "androidx.compose.foundation:fou androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } From f56f782dd3096c2565d09dc74c013f3bd6c80a0a Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 19 Mar 2024 17:56:13 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/jetcaster/ui/MainActivity.kt | 2 +- .../com/example/jetcaster/ui/home/Home.kt | 656 ++++++++-------- .../ui/home/category/PodcastCategory.kt | 470 ++++++------ .../jetcaster/ui/home/discover/Discover.kt | 10 +- .../jetcaster/ui/player/PlayerScreen.kt | 724 +++++++++--------- .../com/example/jetcaster/ui/theme/Color.kt | 16 +- 6 files changed, 939 insertions(+), 939 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index f6d5bb683d..fcf3bbc41e 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -32,7 +32,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge( // This app is only ever in dark mode, so hard code detectDarkMode to true. - //SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) + // SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) ) setContent { 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 7ee947f27d..d69cf59528 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 @@ -85,417 +85,417 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList @Composable fun Home( - navigateToPlayer: (String) -> Unit, - viewModel: HomeViewModel = viewModel() + navigateToPlayer: (String) -> Unit, + viewModel: HomeViewModel = viewModel() ) { - val viewState by viewModel.state.collectAsStateWithLifecycle() - Surface(Modifier.fillMaxSize()) { - Home( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - discoverViewState = viewState.discoverViewState, - podcastCategoryViewState = viewState.podcastCategoryViewState, - libraryEpisodes = viewState.libraryEpisodes, - onHomeCategorySelected = viewModel::onHomeCategorySelected, - onCategorySelected = viewModel::onCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - modifier = Modifier.fillMaxSize() - ) - } + val viewState by viewModel.state.collectAsStateWithLifecycle() + Surface(Modifier.fillMaxSize()) { + Home( + featuredPodcasts = viewState.featuredPodcasts, + isRefreshing = viewState.refreshing, + homeCategories = viewState.homeCategories, + selectedHomeCategory = viewState.selectedHomeCategory, + discoverViewState = viewState.discoverViewState, + podcastCategoryViewState = viewState.podcastCategoryViewState, + libraryEpisodes = viewState.libraryEpisodes, + onHomeCategorySelected = viewModel::onHomeCategorySelected, + onCategorySelected = viewModel::onCategorySelected, + onPodcastUnfollowed = viewModel::onPodcastUnfollowed, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, + modifier = Modifier.fillMaxSize() + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeAppBar( - backgroundColor: Color, - modifier: Modifier = Modifier + backgroundColor: Color, + modifier: Modifier = Modifier ) { - TopAppBar( - title = { - Row { - Image( - painter = painterResource(R.drawable.ic_logo), - contentDescription = null - ) - Icon( - painter = painterResource(R.drawable.ic_text_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier - .padding(start = 4.dp) - .heightIn(max = 24.dp) - ) - } - }, - actions = { - IconButton( - onClick = { /* TODO: Open search */ } - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.cd_search) - ) - } - IconButton( - onClick = { /* TODO: Open account? */ } - ) { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = stringResource(R.string.cd_account) - ) - } - }, - modifier = modifier.background(backgroundColor) - ) + TopAppBar( + title = { + Row { + Image( + painter = painterResource(R.drawable.ic_logo), + contentDescription = null + ) + Icon( + painter = painterResource(R.drawable.ic_text_logo), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier + .padding(start = 4.dp) + .heightIn(max = 24.dp) + ) + } + }, + actions = { + IconButton( + onClick = { /* TODO: Open search */ } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + IconButton( + onClick = { /* TODO: Open account? */ } + ) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) + ) + } + }, + modifier = modifier.background(backgroundColor) + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun Home( - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, + libraryEpisodes: List, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (String) -> Unit, + onHomeCategorySelected: (HomeCategory) -> Unit, + onCategorySelected: (Category) -> Unit, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - Column( - modifier = modifier.windowInsetsPadding( - WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) - ) - ) { - // We dynamically theme this sub-section of the layout to match the selected - // 'top podcast' + Column( + modifier = modifier.windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) + ) + ) { + // We dynamically theme this sub-section of the layout to match the selected + // 'top podcast' - val surfaceColor = MaterialTheme.colorScheme.surface - val appBarColor = surfaceColor.copy(alpha = 0.87f) + val surfaceColor = MaterialTheme.colorScheme.surface + val appBarColor = surfaceColor.copy(alpha = 0.87f) - val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) + val scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) - // Top Bar - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = scrimColor) - ) { - // Draw a scrim over the status bar which matches the app bar - Spacer( - Modifier - .background(appBarColor) - .fillMaxWidth() - .windowInsetsTopHeight(WindowInsets.statusBars) - ) - HomeAppBar( - backgroundColor = appBarColor, - modifier = Modifier.fillMaxWidth() - ) - } + // Top Bar + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = scrimColor) + ) { + // Draw a scrim over the status bar which matches the app bar + Spacer( + Modifier + .background(appBarColor) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.statusBars) + ) + HomeAppBar( + backgroundColor = appBarColor, + modifier = Modifier.fillMaxWidth() + ) + } - // Main Content - HomeContent( - featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, - selectedHomeCategory = selectedHomeCategory, - homeCategories = homeCategories, - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - libraryEpisodes = libraryEpisodes, - scrimColor = scrimColor, - onPodcastUnfollowed = onPodcastUnfollowed, - onHomeCategorySelected = onHomeCategorySelected, - onCategorySelected = onCategorySelected, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed - ) - } + // Main Content + HomeContent( + featuredPodcasts = featuredPodcasts, + isRefreshing = isRefreshing, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + libraryEpisodes = libraryEpisodes, + scrimColor = scrimColor, + onPodcastUnfollowed = onPodcastUnfollowed, + onHomeCategorySelected = onHomeCategorySelected, + onCategorySelected = onCategorySelected, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeContent( - featuredPodcasts: PersistentList, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List, - discoverViewState: DiscoverViewState, - podcastCategoryViewState: PodcastCategoryViewState, - libraryEpisodes: List, - scrimColor: Color, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onHomeCategorySelected: (HomeCategory) -> Unit, - onCategorySelected: (Category) -> Unit, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + featuredPodcasts: PersistentList, + isRefreshing: Boolean, + selectedHomeCategory: HomeCategory, + homeCategories: List, + discoverViewState: DiscoverViewState, + podcastCategoryViewState: PodcastCategoryViewState, + libraryEpisodes: List, + scrimColor: Color, + 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, - onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .fillMaxWidth() - .verticalGradientScrim( - color = scrimColor, - startYPercentage = 1f, - endYPercentage = 0f - ) - ) - } - } + LazyColumn(modifier = modifier.fillMaxSize()) { + if (featuredPodcasts.isNotEmpty()) { + item { + FollowedPodcastItem( + items = featuredPodcasts, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .fillMaxWidth() + .verticalGradientScrim( + color = scrimColor, + startYPercentage = 1f, + endYPercentage = 0f + ) + ) + } + } - if (isRefreshing) { - // TODO show a progress indicator or similar - } + if (isRefreshing) { + // TODO show a progress indicator or similar + } - if (homeCategories.isNotEmpty()) { - stickyHeader { - HomeCategoryTabs( - categories = homeCategories, - selectedCategory = selectedHomeCategory, - onCategorySelected = onHomeCategorySelected - ) - } - } + if (homeCategories.isNotEmpty()) { + stickyHeader { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + onCategorySelected = onHomeCategorySelected + ) + } + } - when (selectedHomeCategory) { - HomeCategory.Library -> { - libraryItems( - episodes = libraryEpisodes, - navigateToPlayer = navigateToPlayer - ) - } + when (selectedHomeCategory) { + HomeCategory.Library -> { + libraryItems( + episodes = libraryEpisodes, + navigateToPlayer = navigateToPlayer + ) + } - HomeCategory.Discover -> { - discoverItems( - discoverViewState = discoverViewState, - podcastCategoryViewState = podcastCategoryViewState, - navigateToPlayer = navigateToPlayer, - onCategorySelected = onCategorySelected, - onTogglePodcastFollowed = onTogglePodcastFollowed - ) - } + HomeCategory.Discover -> { + discoverItems( + discoverViewState = discoverViewState, + podcastCategoryViewState = podcastCategoryViewState, + navigateToPlayer = navigateToPlayer, + onCategorySelected = onCategorySelected, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } + } } - } } @Composable private fun FollowedPodcastItem( - items: PersistentList, - onPodcastUnfollowed: (String) -> Unit, - modifier: Modifier = Modifier, + items: PersistentList, + onPodcastUnfollowed: (String) -> Unit, + modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - Spacer(Modifier.height(16.dp)) + Column(modifier = modifier) { + Spacer(Modifier.height(16.dp)) - FollowedPodcasts( - items = items, - onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - ) + FollowedPodcasts( + items = items, + onPodcastUnfollowed = onPodcastUnfollowed, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) - Spacer(Modifier.height(16.dp)) - } + Spacer(Modifier.height(16.dp)) + } } @Composable private fun HomeCategoryTabs( - categories: List, - selectedCategory: HomeCategory, - onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier + categories: List, + selectedCategory: HomeCategory, + onCategorySelected: (HomeCategory) -> Unit, + modifier: Modifier = Modifier ) { - val selectedIndex = categories.indexOfFirst { it == selectedCategory } - val indicator = @Composable { tabPositions: List -> - HomeCategoryTabIndicator( - Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) - ) - } + val selectedIndex = categories.indexOfFirst { it == selectedCategory } + val indicator = @Composable { tabPositions: List -> + HomeCategoryTabIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) + ) + } - TabRow( - selectedTabIndex = selectedIndex, - indicator = indicator, - modifier = modifier - ) { - categories.forEachIndexed { index, category -> - Tab( - selected = index == selectedIndex, - onClick = { onCategorySelected(category) }, - text = { - Text( - text = when (category) { - HomeCategory.Library -> stringResource(R.string.home_library) - HomeCategory.Discover -> stringResource(R.string.home_discover) - }, - style = MaterialTheme.typography.bodyMedium - ) + TabRow( + selectedTabIndex = selectedIndex, + indicator = indicator, + modifier = modifier + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = index == selectedIndex, + onClick = { onCategorySelected(category) }, + text = { + Text( + text = when (category) { + HomeCategory.Library -> stringResource(R.string.home_library) + HomeCategory.Discover -> stringResource(R.string.home_discover) + }, + style = MaterialTheme.typography.bodyMedium + ) + } + ) } - ) } - } } @Composable fun HomeCategoryTabIndicator( - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onSurface + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface ) { - Spacer( - modifier - .padding(horizontal = 24.dp) - .height(4.dp) - .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) - ) + Spacer( + modifier + .padding(horizontal = 24.dp) + .height(4.dp) + .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) + ) } @Composable fun FollowedPodcasts( - items: PersistentList, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, + items: PersistentList, + modifier: Modifier = Modifier, + onPodcastUnfollowed: (String) -> Unit, ) { - val lastIndex = items.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues( - start = Keyline1, - top = 16.dp, - end = Keyline1, - ) - ) { - itemsIndexed(items) { index: Int, - (podcast, lastEpisodeDate): PodcastWithExtraInfo -> - FollowedPodcastCarouselItem( - podcastImageUrl = podcast.imageUrl, - podcastTitle = podcast.title, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, - modifier = Modifier.padding(4.dp) - ) + val lastIndex = items.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues( + start = Keyline1, + top = 16.dp, + end = Keyline1, + ) + ) { + itemsIndexed(items) { index: Int, + (podcast, lastEpisodeDate): PodcastWithExtraInfo -> + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, + lastEpisodeDateText = lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier.padding(4.dp) + ) - if (index < lastIndex) Spacer(Modifier.width(24.dp)) + if (index < lastIndex) Spacer(Modifier.width(24.dp)) + } } - } } @Composable private fun FollowedPodcastCarouselItem( - modifier: Modifier = Modifier, - podcastImageUrl: String? = null, - podcastTitle: String? = null, - lastEpisodeDateText: String? = null, - onUnfollowedClick: () -> Unit, + modifier: Modifier = Modifier, + podcastImageUrl: String? = null, + podcastTitle: String? = null, + lastEpisodeDateText: String? = null, + onUnfollowedClick: () -> Unit, ) { - Column(modifier) { - Box( - Modifier - .weight(1f) - .align(Alignment.CenterHorizontally) - .aspectRatio(1f) - ) { - if (podcastImageUrl != null) { - AsyncImage( - model = podcastImageUrl, - contentDescription = podcastTitle, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } + Column(modifier) { + Box( + Modifier + .weight(1f) + .align(Alignment.CenterHorizontally) + .aspectRatio(1f) + ) { + if (podcastImageUrl != null) { + AsyncImage( + model = podcastImageUrl, + contentDescription = podcastTitle, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + ) + } - ToggleFollowPodcastIconButton( - onClick = onUnfollowedClick, - isFollowed = true, /* All podcasts are followed in this feed */ - modifier = Modifier.align(Alignment.BottomEnd) - ) - } + ToggleFollowPodcastIconButton( + onClick = onUnfollowedClick, + isFollowed = true, /* All podcasts are followed in this feed */ + modifier = Modifier.align(Alignment.BottomEnd) + ) + } - if (lastEpisodeDateText != null) { - Text( - text = lastEpisodeDateText, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .align(Alignment.CenterHorizontally) - ) + if (lastEpisodeDateText != null) { + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) + } } - } } @Composable private fun lastUpdated(updated: OffsetDateTime): String { - val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) - val days = duration.toDays().toInt() + val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) + val days = duration.toDays().toInt() - return when { - days > 28 -> stringResource(R.string.updated_longer) - days >= 7 -> { - val weeks = days / 7 - quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) - } + return when { + days > 28 -> stringResource(R.string.updated_longer) + days >= 7 -> { + val weeks = days / 7 + quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) + } - days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) - else -> stringResource(R.string.updated_today) - } + days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) + else -> stringResource(R.string.updated_today) + } } @Composable @Preview fun PreviewHomeContent() { - JetcasterTheme { - Home( - featuredPodcasts = PreviewPodcastsWithExtraInfo, - isRefreshing = false, - homeCategories = HomeCategory.entries, - selectedHomeCategory = HomeCategory.Discover, - discoverViewState = DiscoverViewState( - categories = PreviewCategories, - selectedCategory = PreviewCategories.first(), - ), - podcastCategoryViewState = PodcastCategoryViewState( - topPodcasts = PreviewPodcastsWithExtraInfo, - episodes = PreviewEpisodeToPodcasts, - ), - libraryEpisodes = emptyList(), - onCategorySelected = {}, - onPodcastUnfollowed = {}, - navigateToPlayer = {}, - onHomeCategorySelected = {}, - onTogglePodcastFollowed = {} - ) - } + JetcasterTheme { + Home( + featuredPodcasts = PreviewPodcastsWithExtraInfo, + isRefreshing = false, + homeCategories = HomeCategory.entries, + selectedHomeCategory = HomeCategory.Discover, + discoverViewState = DiscoverViewState( + categories = PreviewCategories, + selectedCategory = PreviewCategories.first(), + ), + podcastCategoryViewState = PodcastCategoryViewState( + topPodcasts = PreviewPodcastsWithExtraInfo, + episodes = PreviewEpisodeToPodcasts, + ), + libraryEpisodes = emptyList(), + onCategorySelected = {}, + onPodcastUnfollowed = {}, + navigateToPlayer = {}, + onHomeCategorySelected = {}, + onTogglePodcastFollowed = {} + ) + } } @Composable @Preview fun PreviewPodcastCard() { - JetcasterTheme { - FollowedPodcastCarouselItem( - modifier = Modifier.size(128.dp), - onUnfollowedClick = {} - ) - } + JetcasterTheme { + FollowedPodcastCarouselItem( + modifier = Modifier.size(128.dp), + onUnfollowedClick = {} + ) + } } 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 093b95b460..f11418ddb8 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 @@ -77,290 +77,290 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle data class PodcastCategoryViewState( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() ) fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + topPodcasts: List, + episodes: List, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) - } + item { + CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + } - items(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, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + onTogglePodcastFollowed: (String) -> Unit ) { - CategoryPodcastRow( - podcasts = topPodcasts, - onTogglePodcastFollowed = onTogglePodcastFollowed, - modifier = Modifier.fillMaxWidth() - ) + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + modifier = Modifier.fillMaxWidth() + ) } @Composable fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - modifier: Modifier = Modifier + episode: Episode, + podcast: Podcast, + onClick: (String) -> Unit, + modifier: Modifier = Modifier ) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() + ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { + val ( + divider, episodeTitle, podcastTitle, image, playIcon, + date, addPlaylist, overflow + ) = createRefs() - HorizontalDivider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - width = fillToConstraints - } - ) + HorizontalDivider( + Modifier.constrainAs(divider) { + top.linkTo(parent.top) + centerHorizontallyTo(parent) + width = fillToConstraints + } + ) - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - }, - ) + // If we have an image Url, we can show it using Coil + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + .constrainAs(image) { + end.linkTo(parent.end, 16.dp) + top.linkTo(parent.top, 16.dp) + }, + ) - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.constrainAs(episodeTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(parent.top, 16.dp) + height = preferredWrapContent + width = preferredWrapContent + } ) - top.linkTo(parent.top, 16.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - val titleImageBarrier = createBottomBarrier(podcastTitle, image) + val titleImageBarrier = createBottomBarrier(podcastTitle, image) - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.constrainAs(podcastTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(episodeTitle.bottom, 6.dp) + height = preferredWrapContent + width = preferredWrapContent + } ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp) - ) { /* TODO */ } - .size(48.dp) - .padding(6.dp) - .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } - ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + .constrainAs(playIcon) { + start.linkTo(parent.start, Keyline1) + top.linkTo(titleImageBarrier, margin = 10.dp) + bottom.linkTo(parent.bottom, 10.dp) + } + ) - val duration = episode.duration - Text( - text = when { - duration != null -> { - // If we have the duration, we combine the date/duration via a - // formatted string - stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(episode.published), - duration.toMinutes().toInt() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.constrainAs(date) { + centerVerticallyTo(playIcon) + linkTo( + start = playIcon.end, + startMargin = 12.dp, + end = addPlaylist.start, + endMargin = 16.dp, + bias = 0f // float this towards the start + ) + width = preferredWrapContent + } ) - width = preferredWrapContent - } - ) - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(addPlaylist) { + end.linkTo(overflow.start) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(overflow) { + end.linkTo(parent.end, 8.dp) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - } } @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, - modifier: Modifier = Modifier + podcasts: List, + onTogglePodcastFollowed: (String) -> Unit, + modifier: Modifier = Modifier ) { - val lastIndex = podcasts.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) - ) { - itemsIndexed(items = podcasts) { index: Int, - (podcast, _, isFollowed): PodcastWithExtraInfo -> - TopPodcastRowItem( - podcastTitle = podcast.title, - podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.width(128.dp) - ) + val lastIndex = podcasts.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) + ) { + itemsIndexed(items = podcasts) { index: Int, + (podcast, _, isFollowed): PodcastWithExtraInfo -> + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = isFollowed, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, + modifier = Modifier.width(128.dp) + ) - if (index < lastIndex) Spacer(Modifier.width(24.dp)) + if (index < lastIndex) Spacer(Modifier.width(24.dp)) + } } - } } @Composable private fun TopPodcastRowItem( - podcastTitle: String, - isFollowed: Boolean, - modifier: Modifier = Modifier, - onToggleFollowClicked: () -> Unit, - podcastImageUrl: String? = null, + podcastTitle: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, + podcastImageUrl: String? = null, ) { - Column( - modifier.semantics(mergeDescendants = true) {} - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) + Column( + modifier.semantics(mergeDescendants = true) {} ) { - if (podcastImageUrl != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + ) { + if (podcastImageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + ) + } - ToggleFollowPodcastIconButton( - onClick = onToggleFollowClicked, - isFollowed = isFollowed, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } - Text( - text = podcastTitle, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - } + Text( + text = podcastTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } } private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @Preview @Composable fun PreviewEpisodeListItem() { - JetcasterTheme { - EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], - onClick = { }, - modifier = Modifier.fillMaxWidth() - ) - } + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + modifier = Modifier.fillMaxWidth() + ) + } } 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 e131d59f31..d8665309b1 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 @@ -91,11 +91,11 @@ private fun PodcastCategoryTabs( ) { val selectedIndex = categories.indexOfFirst { it == selectedCategory } ScrollableTabRow( - selectedTabIndex = selectedIndex, - divider = {}, /* Disable the built-in divider */ - edgePadding = Keyline1, - indicator = emptyTabIndicator, - modifier = modifier + selectedTabIndex = selectedIndex, + divider = {}, /* Disable the built-in divider */ + edgePadding = Keyline1, + indicator = emptyTabIndicator, + modifier = modifier ) { categories.forEachIndexed { index, category -> Tab( diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 8b3bcd7dfe..baff47e8be 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -99,13 +99,13 @@ import java.time.Duration */ @Composable fun PlayerScreen( - viewModel: PlayerViewModel, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit + viewModel: PlayerViewModel, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit ) { - val uiState = viewModel.uiState - PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) + val uiState = viewModel.uiState + PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) } /** @@ -113,89 +113,89 @@ fun PlayerScreen( */ @Composable private fun PlayerScreen( - uiState: PlayerUiState, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - Surface(modifier) { - if (uiState.podcastName.isNotEmpty()) { - PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) - } else { - FullScreenLoading() + Surface(modifier) { + if (uiState.podcastName.isNotEmpty()) { + PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) + } else { + FullScreenLoading() + } } - } } @Composable fun PlayerContent( - uiState: PlayerUiState, - windowSizeClass: WindowSizeClass, - displayFeatures: List, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() - // Use a two pane layout if there is a fold impacting layout (meaning it is separating - // or non-flat) or if we have a large enough width to show both. - if ( - windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || - isBookPosture(foldingFeature) || - isTableTopPosture(foldingFeature) || - isSeparatingPosture(foldingFeature) - ) { - // Determine if we are going to be using a vertical strategy (as if laying out - // both sides in a column). We want to do so if we are in a tabletop posture, - // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. - val usingVerticalStrategy = - isTableTopPosture(foldingFeature) || - ( - isSeparatingPosture(foldingFeature) && - foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL - ) + // Use a two pane layout if there is a fold impacting layout (meaning it is separating + // or non-flat) or if we have a large enough width to show both. + if ( + windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded || + isBookPosture(foldingFeature) || + isTableTopPosture(foldingFeature) || + isSeparatingPosture(foldingFeature) + ) { + // Determine if we are going to be using a vertical strategy (as if laying out + // both sides in a column). We want to do so if we are in a tabletop posture, + // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. + val usingVerticalStrategy = + isTableTopPosture(foldingFeature) || + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL + ) - if (usingVerticalStrategy) { - TwoPane( - first = { - PlayerContentTableTopTop(uiState = uiState) - }, - second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) - }, - strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures, - modifier = modifier, - ) - } else { - Column( - modifier = modifier - .fillMaxSize() - .verticalGradientScrim( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f + if (usingVerticalStrategy) { + TwoPane( + first = { + PlayerContentTableTopTop(uiState = uiState) + }, + second = { + PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + }, + strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + modifier = modifier, ) - .systemBarsPadding() - .padding(horizontal = 8.dp) - ) { - TopAppBar(onBackPress = onBackPress) - TwoPane( - first = { - PlayerContentBookStart(uiState = uiState) - }, - second = { - PlayerContentBookEnd(uiState = uiState) - }, - strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures - ) - } + } else { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar(onBackPress = onBackPress) + TwoPane( + first = { + PlayerContentBookStart(uiState = uiState) + }, + second = { + PlayerContentBookEnd(uiState = uiState) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures + ) + } + } + } else { + PlayerContentRegular(uiState, onBackPress, modifier) } - } else { - PlayerContentRegular(uiState, onBackPress, modifier) - } } /** @@ -203,44 +203,44 @@ fun PlayerContent( */ @Composable private fun PlayerContentRegular( - uiState: PlayerUiState, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalGradientScrim( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .systemBarsPadding() - .padding(horizontal = 8.dp) - ) { - TopAppBar(onBackPress = onBackPress) Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) ) { - Spacer(modifier = Modifier.weight(1f)) - PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, - modifier = Modifier.weight(10f) - ) - Spacer(modifier = Modifier.height(32.dp)) - PodcastDescription(uiState.title, uiState.podcastName) - Spacer(modifier = Modifier.height(32.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(10f) - ) { - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) - } - Spacer(modifier = Modifier.weight(1f)) + TopAppBar(onBackPress = onBackPress) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + PlayerImage( + podcastImageUrl = uiState.podcastImageUrl, + modifier = Modifier.weight(10f) + ) + Spacer(modifier = Modifier.height(32.dp)) + PodcastDescription(uiState.title, uiState.podcastName) + Spacer(modifier = Modifier.height(32.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerSlider(uiState.duration) + PlayerButtons(Modifier.padding(vertical = 8.dp)) + } + Spacer(modifier = Modifier.weight(1f)) + } } - } } /** @@ -248,28 +248,28 @@ private fun PlayerContentRegular( */ @Composable private fun PlayerContentTableTopTop( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - // Content for the top part of the screen - Column( - modifier = modifier - .fillMaxWidth() - .verticalGradientScrim( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), - startYPercentage = 1f, - endYPercentage = 0f - ) - .windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Top + // Content for the top part of the screen + Column( + modifier = modifier + .fillMaxWidth() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f ) - ) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - PlayerImage(uiState.podcastImageUrl) - } + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Top + ) + ) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PlayerImage(uiState.podcastImageUrl) + } } /** @@ -277,36 +277,36 @@ private fun PlayerContentTableTopTop( */ @Composable private fun PlayerContentTableTopBottom( - uiState: PlayerUiState, - onBackPress: () -> Unit, - modifier: Modifier = Modifier + uiState: PlayerUiState, + onBackPress: () -> Unit, + modifier: Modifier = Modifier ) { - // Content for the table part of the screen - Column( - modifier = modifier - .windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom - ) - ) - .padding(horizontal = 32.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TopAppBar(onBackPress = onBackPress) - PodcastDescription( - title = uiState.title, - podcastName = uiState.podcastName, - titleTextStyle = MaterialTheme.typography.titleLarge - ) - Spacer(modifier = Modifier.weight(0.5f)) + // Content for the table part of the screen Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(10f) + modifier = modifier + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) - PlayerSlider(uiState.duration) + TopAppBar(onBackPress = onBackPress) + PodcastDescription( + title = uiState.title, + podcastName = uiState.podcastName, + titleTextStyle = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.weight(0.5f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) + PlayerSlider(uiState.duration) + } } - } } /** @@ -314,28 +314,28 @@ private fun PlayerContentTableTopBottom( */ @Composable private fun PlayerContentBookStart( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - vertical = 8.dp, - horizontal = 16.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround - ) { - Spacer(modifier = Modifier.height(32.dp)) - PodcastInformation( - uiState.title, - uiState.podcastName, - uiState.summary - ) - Spacer(modifier = Modifier.height(32.dp)) - } + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + vertical = 8.dp, + horizontal = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround + ) { + Spacer(modifier = Modifier.height(32.dp)) + PodcastInformation( + uiState.title, + uiState.podcastName, + uiState.summary + ) + Spacer(modifier = Modifier.height(32.dp)) + } } /** @@ -343,202 +343,202 @@ private fun PlayerContentBookStart( */ @Composable private fun PlayerContentBookEnd( - uiState: PlayerUiState, - modifier: Modifier = Modifier + uiState: PlayerUiState, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround, - ) { - PlayerImage( - podcastImageUrl = uiState.podcastImageUrl, - modifier = Modifier - .padding(vertical = 16.dp) - .weight(1f) - ) - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) - } + Column( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + ) { + PlayerImage( + podcastImageUrl = uiState.podcastImageUrl, + modifier = Modifier + .padding(vertical = 16.dp) + .weight(1f) + ) + PlayerSlider(uiState.duration) + PlayerButtons(Modifier.padding(vertical = 8.dp)) + } } @Composable private fun TopAppBar(onBackPress: () -> Unit) { - Row(Modifier.fillMaxWidth()) { - IconButton(onClick = onBackPress) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back) - ) - } - Spacer(Modifier.weight(1f)) - IconButton(onClick = { /* TODO */ }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add) - ) - } - IconButton(onClick = { /* TODO */ }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more) - ) + Row(Modifier.fillMaxWidth()) { + IconButton(onClick = onBackPress) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } } - } } @Composable private fun PlayerImage( - podcastImageUrl: String, - modifier: Modifier = Modifier + podcastImageUrl: String, + modifier: Modifier = Modifier ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) - .aspectRatio(1f) - .clip(MaterialTheme.shapes.medium) - ) + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + ) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun PodcastDescription( - title: String, - podcastName: String, - titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall + title: String, + podcastName: String, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall ) { - Text( - text = title, - style = titleTextStyle, - maxLines = 1, - modifier = Modifier.basicMarquee() - ) - Text( - text = podcastName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1 - ) + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + modifier = Modifier.basicMarquee() + ) + Text( + text = podcastName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) } @Composable private fun PodcastInformation( - title: String, - name: String, - summary: String, - titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, - nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, + title: String, + name: String, + summary: String, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Text( - text = name, - style = nameTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = title, - style = titleTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = summary, - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.weight(1f)) - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = name, + style = nameTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.weight(1f)) + } } @Composable private fun PlayerSlider(episodeDuration: Duration?) { - if (episodeDuration != null) { - Column(Modifier.fillMaxWidth()) { - Slider(value = 0f, onValueChange = { }) - Row(Modifier.fillMaxWidth()) { - Text(text = "0s") - Spacer(modifier = Modifier.weight(1f)) - Text("${episodeDuration.seconds}s") - } + if (episodeDuration != null) { + Column(Modifier.fillMaxWidth()) { + Slider(value = 0f, onValueChange = { }) + Row(Modifier.fillMaxWidth()) { + Text(text = "0s") + Spacer(modifier = Modifier.weight(1f)) + Text("${episodeDuration.seconds}s") + } + } } - } } @Composable private fun PlayerButtons( - modifier: Modifier = Modifier, - playerButtonSize: Dp = 72.dp, - sideButtonSize: Dp = 48.dp + modifier: Modifier = Modifier, + playerButtonSize: Dp = 72.dp, + sideButtonSize: Dp = 48.dp ) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly - ) { - val buttonsModifier = Modifier - .size(sideButtonSize) - .semantics { role = Role.Button } + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val buttonsModifier = Modifier + .size(sideButtonSize) + .semantics { role = Role.Button } - Image( - imageVector = Icons.Filled.SkipPrevious, - contentDescription = stringResource(R.string.cd_skip_previous), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Filled.Replay10, - contentDescription = stringResource(R.string.cd_reply10), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } - ) - Image( - imageVector = Icons.Filled.Forward30, - contentDescription = stringResource(R.string.cd_forward30), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - Image( - imageVector = Icons.Filled.SkipNext, - contentDescription = stringResource(R.string.cd_skip_next), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = buttonsModifier - ) - } + Image( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.cd_skip_previous), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Filled.Replay10, + contentDescription = stringResource(R.string.cd_reply10), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + ) + Image( + imageVector = Icons.Filled.Forward30, + contentDescription = stringResource(R.string.cd_forward30), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + Image( + imageVector = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.cd_skip_next), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = buttonsModifier + ) + } } /** * Theme that updates the colors dynamically depending on the podcast image URL */ -//@Composable -//private fun PlayerDynamicTheme( +// @Composable +// private fun PlayerDynamicTheme( // podcastImageUrl: String, // content: @Composable () -> Unit -//) { +// ) { // val surfaceColor = MaterialTheme.colors.surface // val dominantColorState = rememberDominantColorState( // defaultColor = MaterialTheme.colors.surface @@ -557,36 +557,36 @@ private fun PlayerButtons( // } // content() // } -//} +// } /** * Full screen circular progress indicator */ @Composable private fun FullScreenLoading(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) { - CircularProgressIndicator() - } + Box( + modifier = modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + } } @Preview @Composable fun TopAppBarPreview() { - JetcasterTheme { - TopAppBar(onBackPress = { }) - } + JetcasterTheme { + TopAppBar(onBackPress = { }) + } } @Preview @Composable fun PlayerButtonsPreview() { - JetcasterTheme { - PlayerButtons() - } + JetcasterTheme { + PlayerButtons() + } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @@ -596,18 +596,18 @@ fun PlayerButtonsPreview() { @Preview(device = Devices.DESKTOP) @Composable fun PlayerScreenPreview() { - JetcasterTheme { - BoxWithConstraints { - PlayerScreen( - PlayerUiState( - title = "Title", - duration = Duration.ofHours(2), - podcastName = "Podcast" - ), - displayFeatures = emptyList(), - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), - onBackPress = { } - ) + JetcasterTheme { + BoxWithConstraints { + PlayerScreen( + PlayerUiState( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast" + ), + displayFeatures = emptyList(), + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), + onBackPress = { } + ) + } } - } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt index 89f0a683f3..f264ef8937 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -23,19 +23,19 @@ package com.example.jetcaster.ui.theme */ const val MinContrastOfPrimaryVsSurface = 3f -///** +// /** // * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the // * given [alpha]. Useful for situations where semi-transparent colors are undesirable. // */ -//@Composable -//fun Colors.compositedOnSurface(alpha: Float): Color { +// @Composable +// fun Colors.compositedOnSurface(alpha: Float): Color { // return onSurface.copy(alpha = alpha).compositeOver(surface) -//} +// } // -//val Yellow800 = Color(0xFFF29F05) -//val Red300 = Color(0xFFEA6D7E) +// val Yellow800 = Color(0xFFF29F05) +// val Red300 = Color(0xFFEA6D7E) // -//val JetcasterColors = darkColors( +// val JetcasterColors = darkColors( // primary = Yellow800, // onPrimary = Color.Black, // primaryVariant = Yellow800, @@ -43,4 +43,4 @@ const val MinContrastOfPrimaryVsSurface = 3f // onSecondary = Color.Black, // error = Red300, // onError = Color.Black -//) +// ) From 583d2d05c05910d698ce5729cc04fbb412e0ee3d Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 19 Mar 2024 10:59:38 -0700 Subject: [PATCH 03/10] Remove commented out code. --- .../com/example/jetcaster/ui/MainActivity.kt | 5 +--- .../jetcaster/ui/player/PlayerScreen.kt | 28 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt index fcf3bbc41e..8218625f3b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -30,10 +30,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge( - // This app is only ever in dark mode, so hard code detectDarkMode to true. - // SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode = { true }) - ) + enableEdgeToEdge() setContent { val windowSizeClass = calculateWindowSizeClass(this) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index baff47e8be..27d73dd8bd 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -531,34 +531,6 @@ private fun PlayerButtons( } } -/** - * Theme that updates the colors dynamically depending on the podcast image URL - */ -// @Composable -// private fun PlayerDynamicTheme( -// podcastImageUrl: String, -// content: @Composable () -> Unit -// ) { -// val surfaceColor = MaterialTheme.colors.surface -// val dominantColorState = rememberDominantColorState( -// defaultColor = MaterialTheme.colors.surface -// ) { color -> -// // We want a color which has sufficient contrast against the surface color -// color.contrastAgainst(surfaceColor) >= MinContrastOfPrimaryVsSurface -// } -// DynamicThemePrimaryColorsFromImage(dominantColorState) { -// // Update the dominantColorState with colors coming from the podcast image URL -// LaunchedEffect(podcastImageUrl) { -// if (podcastImageUrl.isNotEmpty()) { -// dominantColorState.updateColorsFromImageUrl(podcastImageUrl) -// } else { -// dominantColorState.reset() -// } -// } -// content() -// } -// } - /** * Full screen circular progress indicator */ From 251d976d56f2158eded8964182c794c7b910b43c Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 19 Mar 2024 11:01:06 -0700 Subject: [PATCH 04/10] Remove mention of dynamic theming. --- Jetcaster/README.md | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 364c4165b2..0fbbe55c11 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -3,7 +3,7 @@ # Jetcaster sample 🎙️ Jetcaster is a sample podcast app, built with [Jetpack Compose][compose]. The goal of the sample is to -showcase dynamic theming and full featured architecture. +showcase building with Compose across multiple form factors and full featured architecture. To try out this sample app, use the latest stable version of [Android Studio](https://developer.android.com/studio). @@ -14,7 +14,7 @@ project from Android Studio following the steps ### Status: 🚧 In progress 🚧 Jetcaster is still in the early stages of development, and as such only one screen has been created so far. However, -most of the app's architecture has been implemented, as well as the data layer, and early stages of dynamic theming. +most of the app's architecture has been implemented as well as the data layer. ## Screenshots @@ -36,29 +36,6 @@ The player screen layout is adapting to different form factors, including a tabl -### Dynamic theming -The home screen currently implements dynamic theming, using the artwork of the currently selected podcast from the carousel to update the `primary` and `onPrimary` [colors](https://developer.android.com/reference/kotlin/androidx/compose/material/Colors). You can see it in action in the screenshots above: as the carousel item is changed, the background gradient is updated to match the artwork. - -This is implemented in [`DynamicTheming.kt`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt), which provides the `DynamicThemePrimaryColorsFromImage` composable, to automatically animate the theme colors based on the provided image URL, like so: - -``` kotlin -val dominantColorState: DominantColorState = rememberDominantColorState() - -DynamicThemePrimaryColorsFromImage(dominantColorState) { - var imageUrl = remember { mutableStateOf("") } - - // When the image url changes, call updateColorsFromImageUrl() - launchInComposition(imageUrl) { - dominantColorState.updateColorsFromImageUrl(imageUrl) - } - - // Content which will be dynamically themed.... -} -``` - -Underneath, [`DominantColorState`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt) uses the [Coil][coil] library to fetch the artwork image 🖼️, and then [Palette][palette] to extract the dominant colors from the image 🎨. - - ### Others Some other notable things which are implemented: From fe2d69c5706f58c6e80cde4feacbd48ea8b5c5ee Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 19 Mar 2024 13:39:05 -0700 Subject: [PATCH 05/10] Minor UI tweaks. --- .../ui/home/category/PodcastCategory.kt | 473 +++++++++--------- .../jetcaster/ui/home/library/Library.kt | 28 +- 2 files changed, 263 insertions(+), 238 deletions(-) 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 f11418ddb8..0c3c938694 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 @@ -77,290 +77,293 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle data class PodcastCategoryViewState( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() ) fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + topPodcasts: List, + episodes: List, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) - } + item { + CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + } - items(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, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + onTogglePodcastFollowed: (String) -> Unit ) { - CategoryPodcastRow( - podcasts = topPodcasts, - onTogglePodcastFollowed = onTogglePodcastFollowed, - modifier = Modifier.fillMaxWidth() - ) + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + modifier = Modifier.fillMaxWidth() + ) } @Composable fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - modifier: Modifier = Modifier + episode: Episode, + podcast: Podcast, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, ) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() + ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { + val ( + divider, episodeTitle, podcastTitle, image, playIcon, + date, addPlaylist, overflow + ) = createRefs() - HorizontalDivider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - width = fillToConstraints - } - ) + if (showDivider) { + HorizontalDivider( + Modifier.constrainAs(divider) { + top.linkTo(parent.top) + centerHorizontallyTo(parent) + width = fillToConstraints + } + ) + } - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - }, - ) + // If we have an image Url, we can show it using Coil + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + .constrainAs(image) { + end.linkTo(parent.end, 16.dp) + top.linkTo(parent.top, 16.dp) + }, + ) - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(parent.top, 16.dp) - height = preferredWrapContent - width = preferredWrapContent - } + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.constrainAs(episodeTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f ) + top.linkTo(parent.top, 16.dp) + height = preferredWrapContent + width = preferredWrapContent + } + ) - val titleImageBarrier = createBottomBarrier(podcastTitle, image) + val titleImageBarrier = createBottomBarrier(podcastTitle, image) - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = preferredWrapContent - width = preferredWrapContent - } + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.constrainAs(podcastTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f ) + top.linkTo(episodeTitle.bottom, 6.dp) + height = preferredWrapContent + width = preferredWrapContent + } + ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp) - ) { /* TODO */ } - .size(48.dp) - .padding(6.dp) - .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } - ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + .constrainAs(playIcon) { + start.linkTo(parent.start, Keyline1) + top.linkTo(titleImageBarrier, margin = 10.dp) + bottom.linkTo(parent.bottom, 10.dp) + } + ) - val duration = episode.duration - Text( - text = when { - duration != null -> { - // If we have the duration, we combine the date/duration via a - // formatted string - stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(episode.published), - duration.toMinutes().toInt() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start - ) - width = preferredWrapContent - } + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.constrainAs(date) { + centerVerticallyTo(playIcon) + linkTo( + start = playIcon.end, + startMargin = 12.dp, + end = addPlaylist.start, + endMargin = 16.dp, + bias = 0f // float this towards the start ) + width = preferredWrapContent + } + ) - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(addPlaylist) { + end.linkTo(overflow.start) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(overflow) { + end.linkTo(parent.end, 8.dp) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } } @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, - modifier: Modifier = Modifier + podcasts: List, + onTogglePodcastFollowed: (String) -> Unit, + modifier: Modifier = Modifier ) { - val lastIndex = podcasts.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) - ) { - itemsIndexed(items = podcasts) { index: Int, - (podcast, _, isFollowed): PodcastWithExtraInfo -> - TopPodcastRowItem( - podcastTitle = podcast.title, - podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.width(128.dp) - ) + val lastIndex = podcasts.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) + ) { + itemsIndexed(items = podcasts) { index: Int, + (podcast, _, isFollowed): PodcastWithExtraInfo -> + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = isFollowed, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, + modifier = Modifier.width(128.dp) + ) - if (index < lastIndex) Spacer(Modifier.width(24.dp)) - } + if (index < lastIndex) Spacer(Modifier.width(24.dp)) } + } } @Composable private fun TopPodcastRowItem( - podcastTitle: String, - isFollowed: Boolean, - modifier: Modifier = Modifier, - onToggleFollowClicked: () -> Unit, - podcastImageUrl: String? = null, + podcastTitle: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, + podcastImageUrl: String? = null, ) { - Column( - modifier.semantics(mergeDescendants = true) {} + Column( + modifier.semantics(mergeDescendants = true) {} + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) - ) { - if (podcastImageUrl != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } - - ToggleFollowPodcastIconButton( - onClick = onToggleFollowClicked, - isFollowed = isFollowed, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - - Text( - text = podcastTitle, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() + if (podcastImageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), ) + } + + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.BottomEnd) + ) } + + Text( + text = podcastTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } } private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @Preview @Composable fun PreviewEpisodeListItem() { - JetcasterTheme { - EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], - onClick = { }, - modifier = Modifier.fillMaxWidth() - ) - } + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + modifier = Modifier.fillMaxWidth() + ) + } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt index 490ea28c9b..4d505051bb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -16,10 +16,17 @@ package com.example.jetcaster.ui.home.library +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.designsystem.theme.Keyline1 import com.example.jetcaster.ui.home.category.EpisodeListItem fun LazyListScope.libraryItems( @@ -31,12 +38,27 @@ fun LazyListScope.libraryItems( return } - items(episodes, key = { it.episode.uri }) { item -> + item { + Text( + text = stringResource(id = R.string.latest_episodes), + modifier = Modifier.padding( + start = Keyline1, + top = 16.dp, + ), + style = MaterialTheme.typography.titleLarge, + ) + } + + itemsIndexed( + episodes, + key = { _, item -> item.episode.uri } + ) { index, item -> EpisodeListItem( episode = item.episode, podcast = item.podcast, onClick = navigateToPlayer, - modifier = Modifier.fillParentMaxWidth() + modifier = Modifier.fillParentMaxWidth(), + showDivider = index != 0 ) } } From 7c7d97e5b368dc14d0145d18dbf889300e7c8b2b Mon Sep 17 00:00:00 2001 From: arriolac Date: Tue, 19 Mar 2024 20:41:24 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/home/category/PodcastCategory.kt | 474 +++++++++--------- 1 file changed, 237 insertions(+), 237 deletions(-) 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 0c3c938694..4deab17146 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 @@ -77,293 +77,293 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle data class PodcastCategoryViewState( - val topPodcasts: List = emptyList(), - val episodes: List = emptyList() + val topPodcasts: List = emptyList(), + val episodes: List = emptyList() ) fun LazyListScope.podcastCategory( - topPodcasts: List, - episodes: List, - navigateToPlayer: (String) -> Unit, - onTogglePodcastFollowed: (String) -> Unit, + topPodcasts: List, + episodes: List, + navigateToPlayer: (String) -> Unit, + onTogglePodcastFollowed: (String) -> Unit, ) { - item { - CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) - } + item { + CategoryPodcasts(topPodcasts, onTogglePodcastFollowed) + } - items(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, - onTogglePodcastFollowed: (String) -> Unit + topPodcasts: List, + onTogglePodcastFollowed: (String) -> Unit ) { - CategoryPodcastRow( - podcasts = topPodcasts, - onTogglePodcastFollowed = onTogglePodcastFollowed, - modifier = Modifier.fillMaxWidth() - ) + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + modifier = Modifier.fillMaxWidth() + ) } @Composable fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - onClick: (String) -> Unit, - modifier: Modifier = Modifier, - showDivider: Boolean = true, + episode: Episode, + podcast: Podcast, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, ) { - ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() + ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { + val ( + divider, episodeTitle, podcastTitle, image, playIcon, + date, addPlaylist, overflow + ) = createRefs() - if (showDivider) { - HorizontalDivider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - width = fillToConstraints + if (showDivider) { + HorizontalDivider( + Modifier.constrainAs(divider) { + top.linkTo(parent.top) + centerHorizontallyTo(parent) + width = fillToConstraints + } + ) } - ) - } - // If we have an image Url, we can show it using Coil - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - }, - ) + // If we have an image Url, we can show it using Coil + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcast.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + .constrainAs(image) { + end.linkTo(parent.end, 16.dp) + top.linkTo(parent.top, 16.dp) + }, + ) - Text( - text = episode.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + Text( + text = episode.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.constrainAs(episodeTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(parent.top, 16.dp) + height = preferredWrapContent + width = preferredWrapContent + } ) - top.linkTo(parent.top, 16.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - val titleImageBarrier = createBottomBarrier(podcastTitle, image) + val titleImageBarrier = createBottomBarrier(podcastTitle, image) - Text( - text = podcast.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.constrainAs(podcastTitle) { + linkTo( + start = parent.start, + end = image.start, + startMargin = Keyline1, + endMargin = 16.dp, + bias = 0f + ) + top.linkTo(episodeTitle.bottom, 6.dp) + height = preferredWrapContent + width = preferredWrapContent + } ) - top.linkTo(episodeTitle.bottom, 6.dp) - height = preferredWrapContent - width = preferredWrapContent - } - ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp) - ) { /* TODO */ } - .size(48.dp) - .padding(6.dp) - .semantics { role = Role.Button } - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 10.dp) - bottom.linkTo(parent.bottom, 10.dp) - } - ) + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + .constrainAs(playIcon) { + start.linkTo(parent.start, Keyline1) + top.linkTo(titleImageBarrier, margin = 10.dp) + bottom.linkTo(parent.bottom, 10.dp) + } + ) - val duration = episode.duration - Text( - text = when { - duration != null -> { - // If we have the duration, we combine the date/duration via a - // formatted string - stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(episode.published), - duration.toMinutes().toInt() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.constrainAs(date) { + centerVerticallyTo(playIcon) + linkTo( + start = playIcon.end, + startMargin = 12.dp, + end = addPlaylist.start, + endMargin = 16.dp, + bias = 0f // float this towards the start + ) + width = preferredWrapContent + } ) - width = preferredWrapContent - } - ) - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(R.string.cd_add), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(addPlaylist) { + end.linkTo(overflow.start) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.constrainAs(overflow) { + end.linkTo(parent.end, 8.dp) + centerVerticallyTo(playIcon) + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - } } @Composable private fun CategoryPodcastRow( - podcasts: List, - onTogglePodcastFollowed: (String) -> Unit, - modifier: Modifier = Modifier + podcasts: List, + onTogglePodcastFollowed: (String) -> Unit, + modifier: Modifier = Modifier ) { - val lastIndex = podcasts.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) - ) { - itemsIndexed(items = podcasts) { index: Int, - (podcast, _, isFollowed): PodcastWithExtraInfo -> - TopPodcastRowItem( - podcastTitle = podcast.title, - podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.width(128.dp) - ) + val lastIndex = podcasts.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) + ) { + itemsIndexed(items = podcasts) { index: Int, + (podcast, _, isFollowed): PodcastWithExtraInfo -> + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = isFollowed, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, + modifier = Modifier.width(128.dp) + ) - if (index < lastIndex) Spacer(Modifier.width(24.dp)) + if (index < lastIndex) Spacer(Modifier.width(24.dp)) + } } - } } @Composable private fun TopPodcastRowItem( - podcastTitle: String, - isFollowed: Boolean, - modifier: Modifier = Modifier, - onToggleFollowClicked: () -> Unit, - podcastImageUrl: String? = null, + podcastTitle: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, + podcastImageUrl: String? = null, ) { - Column( - modifier.semantics(mergeDescendants = true) {} - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) + Column( + modifier.semantics(mergeDescendants = true) {} ) { - if (podcastImageUrl != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcastImageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium), - ) - } + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + ) { + if (podcastImageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + ) + } - ToggleFollowPodcastIconButton( - onClick = onToggleFollowClicked, - isFollowed = isFollowed, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } - Text( - text = podcastTitle, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - } + Text( + text = podcastTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } } private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @Preview @Composable fun PreviewEpisodeListItem() { - JetcasterTheme { - EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], - onClick = { }, - modifier = Modifier.fillMaxWidth() - ) - } + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + modifier = Modifier.fillMaxWidth() + ) + } } From d8cc8c91de4d75f49be618764c08384fc0dfbeb1 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Tue, 19 Mar 2024 17:10:34 -0700 Subject: [PATCH 07/10] PR feedback. --- .../com/example/jetcaster/ui/theme/Color.kt | 22 ------------------- .../com/example/jetcaster/ui/theme/Theme.kt | 3 ++- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt index f264ef8937..5193851599 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -22,25 +22,3 @@ package com.example.jetcaster.ui.theme * 3:1 which is the minimum for user-interface components. */ const val MinContrastOfPrimaryVsSurface = 3f - -// /** -// * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the -// * given [alpha]. Useful for situations where semi-transparent colors are undesirable. -// */ -// @Composable -// fun Colors.compositedOnSurface(alpha: Float): Color { -// return onSurface.copy(alpha = alpha).compositeOver(surface) -// } -// -// val Yellow800 = Color(0xFFF29F05) -// val Red300 = Color(0xFFEA6D7E) -// -// val JetcasterColors = darkColors( -// primary = Yellow800, -// onPrimary = Color.Black, -// primaryVariant = Yellow800, -// secondary = Yellow800, -// onSecondary = Color.Black, -// error = Red300, -// onError = Color.Black -// ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt index 984ffa9e7f..014637d374 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -502,10 +502,11 @@ fun JetcasterTheme( else -> lightScheme } val view = LocalView.current + val statusBarColor = colorScheme.surface if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = Color.Transparent.toArgb() + window.statusBarColor = statusBarColor.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme } } From 4a36c17d820e85cd434d0a0d7a1e793d592ca1b2 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 20 Mar 2024 10:58:47 -0700 Subject: [PATCH 08/10] Fix status bar contrast. --- .../app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt index 014637d374..d22c5c1f63 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -507,7 +507,7 @@ fun JetcasterTheme( SideEffect { val window = (view.context as Activity).window window.statusBarColor = statusBarColor.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } From c77667e1737eb523fde29e6630257e01a34e3d47 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 20 Mar 2024 11:11:39 -0700 Subject: [PATCH 09/10] Add comment to update section to a carousel once better support is provided. --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 d69cf59528..e312401a0a 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 @@ -85,10 +85,10 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim +import kotlinx.collections.immutable.PersistentList import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime -import kotlinx.collections.immutable.PersistentList @Composable fun Home( @@ -374,6 +374,7 @@ fun FollowedPodcasts( 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, From 708aefe04dae09c7ef144f0dc1e1d1bc6d392322 Mon Sep 17 00:00:00 2001 From: arriolac Date: Wed, 20 Mar 2024 18:14:15 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/example/jetcaster/ui/home/Home.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e312401a0a..d081d5cedc 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 @@ -85,10 +85,10 @@ import com.example.jetcaster.ui.theme.JetcasterTheme import com.example.jetcaster.util.ToggleFollowPodcastIconButton import com.example.jetcaster.util.quantityStringResource import com.example.jetcaster.util.verticalGradientScrim -import kotlinx.collections.immutable.PersistentList import java.time.Duration import java.time.LocalDateTime import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList @Composable fun Home(