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: 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..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 @@ -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 @@ -32,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/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index b7db0741ad..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 @@ -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,18 +76,14 @@ 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 java.time.Duration import java.time.LocalDateTime @@ -121,6 +115,7 @@ fun Home( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeAppBar( backgroundColor: Color, @@ -142,28 +137,25 @@ fun HomeAppBar( ) } }, - 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) - ) - } + 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 + modifier = modifier.background(backgroundColor) ) } @@ -192,67 +184,46 @@ fun Home( // We dynamically theme this sub-section of the layout to match the selected // 'top podcast' - val surfaceColor = MaterialTheme.colors.surface + val surfaceColor = MaterialTheme.colorScheme.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 scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) - 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() - } - } - - val scrimColor = MaterialTheme.colors.primary.copy(alpha = 0.38f) - - // Top Bar - Column( - modifier = Modifier + // 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() - .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, - pagerState = pagerState, - onPodcastUnfollowed = onPodcastUnfollowed, - onHomeCategorySelected = onHomeCategorySelected, - onCategorySelected = onCategorySelected, - navigateToPlayer = navigateToPlayer, - onTogglePodcastFollowed = onTogglePodcastFollowed + .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 + ) } } @@ -267,7 +238,6 @@ private fun HomeContent( podcastCategoryViewState: PodcastCategoryViewState, libraryEpisodes: List, scrimColor: Color, - pagerState: PagerState, modifier: Modifier = Modifier, onPodcastUnfollowed: (String) -> Unit, onHomeCategorySelected: (HomeCategory) -> Unit, @@ -280,7 +250,6 @@ private fun HomeContent( item { FollowedPodcastItem( items = featuredPodcasts, - pagerState = pagerState, onPodcastUnfollowed = onPodcastUnfollowed, modifier = Modifier .fillMaxWidth() @@ -328,11 +297,9 @@ private fun HomeContent( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun FollowedPodcastItem( items: PersistentList, - pagerState: PagerState, onPodcastUnfollowed: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -341,10 +308,8 @@ private fun FollowedPodcastItem( FollowedPodcasts( items = items, - pagerState = pagerState, onPodcastUnfollowed = onPodcastUnfollowed, modifier = Modifier - .padding(start = Keyline1, top = 16.dp, end = Keyline1) .fillMaxWidth() .height(200.dp) ) @@ -382,7 +347,7 @@ private fun HomeCategoryTabs( HomeCategory.Library -> stringResource(R.string.home_library) HomeCategory.Discover -> stringResource(R.string.home_discover) }, - style = MaterialTheme.typography.body2 + style = MaterialTheme.typography.bodyMedium ) } ) @@ -393,7 +358,7 @@ private fun HomeCategoryTabs( @Composable fun HomeCategoryTabIndicator( modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.onSurface + color: Color = MaterialTheme.colorScheme.onSurface ) { Spacer( modifier @@ -403,28 +368,34 @@ fun HomeCategoryTabIndicator( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun FollowedPodcasts( items: PersistentList, - pagerState: PagerState, 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() + // TODO: Update this component to a carousel once better support is available + val lastIndex = items.size - 1 + LazyRow( + modifier = modifier, + contentPadding = PaddingValues( + start = Keyline1, + top = 16.dp, + end = Keyline1, ) + ) { + itemsIndexed(items) { index: Int, + (podcast, lastEpisodeDate): PodcastWithExtraInfo -> + 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)) + } } } @@ -436,9 +407,7 @@ private fun FollowedPodcastCarouselItem( lastEpisodeDateText: String? = null, onUnfollowedClick: () -> Unit, ) { - Column( - modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) { + Column(modifier) { Box( Modifier .weight(1f) @@ -464,17 +433,15 @@ private fun FollowedPodcastCarouselItem( } 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) - ) - } + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) } } } 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..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 @@ -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,10 +68,10 @@ 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 @@ -84,6 +80,7 @@ data class PodcastCategoryViewState( val topPodcasts: List = emptyList(), val episodes: List = emptyList() ) + fun LazyListScope.podcastCategory( topPodcasts: List, episodes: List, @@ -121,7 +118,8 @@ fun EpisodeListItem( episode: Episode, podcast: Podcast, onClick: (String) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showDivider: Boolean = true, ) { ConstraintLayout(modifier = modifier.clickable { onClick(episode.uri) }) { val ( @@ -129,14 +127,15 @@ fun EpisodeListItem( date, addPlaylist, overflow ) = createRefs() - Divider( - 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( @@ -159,7 +158,7 @@ fun EpisodeListItem( text = episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.constrainAs(episodeTitle) { linkTo( start = parent.start, @@ -176,32 +175,30 @@ fun EpisodeListItem( val titleImageBarrier = createBottomBarrier(podcastTitle, image) - 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 - } - ) - } + 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(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -217,63 +214,63 @@ fun EpisodeListItem( } ) - 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 + 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() ) - 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) + // 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(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(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 + ) } } } @@ -344,7 +341,7 @@ private fun TopPodcastRowItem( Text( text = podcastTitle, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier 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..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 @@ -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(), @@ -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/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 ) } } 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..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 @@ -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 @@ -144,67 +136,65 @@ fun PlayerContent( 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) || + // 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) - ) { - // 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 - ) + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL + ) - if (usingVerticalStrategy) { + 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 = { - PlayerContentTableTopTop(uiState = uiState) + PlayerContentBookStart(uiState = uiState) }, second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + PlayerContentBookEnd(uiState = uiState) }, - strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), - displayFeatures = displayFeatures, - modifier = modifier, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures ) - } 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) } + } else { + PlayerContentRegular(uiState, onBackPress, modifier) } } @@ -221,7 +211,7 @@ private fun PlayerContentRegular( modifier = modifier .fillMaxSize() .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), startYPercentage = 1f, endYPercentage = 0f ) @@ -266,7 +256,7 @@ private fun PlayerContentTableTopTop( modifier = modifier .fillMaxWidth() .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.50f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), startYPercentage = 1f, endYPercentage = 0f ) @@ -306,7 +296,7 @@ private fun PlayerContentTableTopBottom( PodcastDescription( title = uiState.title, podcastName = uiState.podcastName, - titleTextStyle = MaterialTheme.typography.h6 + titleTextStyle = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.weight(0.5f)) Column( @@ -379,14 +369,14 @@ private fun TopAppBar(onBackPress: () -> Unit) { Row(Modifier.fillMaxWidth()) { IconButton(onClick = onBackPress) { Icon( - imageVector = Icons.Default.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back) ) } Spacer(Modifier.weight(1f)) IconButton(onClick = { /* TODO */ }) { Icon( - imageVector = Icons.Default.PlaylistAdd, + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.cd_add) ) } @@ -423,7 +413,7 @@ private fun PlayerImage( private fun PodcastDescription( title: String, podcastName: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5 + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall ) { Text( text = title, @@ -431,13 +421,11 @@ private fun PodcastDescription( maxLines = 1, modifier = Modifier.basicMarquee() ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = podcastName, - style = MaterialTheme.typography.body2, - maxLines = 1 - ) - } + Text( + text = podcastName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) } @Composable @@ -445,8 +433,8 @@ private fun PodcastInformation( title: String, name: String, summary: String, - titleTextStyle: TextStyle = MaterialTheme.typography.h5, - nameTextStyle: TextStyle = MaterialTheme.typography.h3, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -466,12 +454,10 @@ private fun PodcastInformation( overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(32.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = summary, - style = MaterialTheme.typography.body2, - ) - } + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) Spacer(modifier = Modifier.weight(1f)) } } @@ -545,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 */ 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..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 @@ -16,37 +16,9 @@ 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 * 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 a6c728a5eb..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 @@ -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 { @@ -512,17 +502,19 @@ fun JetcasterThemeM3( else -> lightScheme } val view = LocalView.current + val statusBarColor = colorScheme.surface if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + window.statusBarColor = statusBarColor.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" }