From 3ef67f1f789752ce4a9814f71e39fa2b3e7d5354 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Thu, 3 Apr 2025 22:50:28 +0100 Subject: [PATCH 1/4] Migrate all screens except Player to Material3 Change-Id: I71ffe54e4537015201fd3411144d5f00ac5f1537 --- Jetcaster/gradle/libs.versions.toml | 11 +- Jetcaster/wear/build.gradle | 11 +- .../com/example/jetcaster/MainActivity.kt | 1 - .../java/com/example/jetcaster/WearApp.kt | 9 +- .../java/com/example/jetcaster/theme/Color.kt | 38 -- .../example/jetcaster/theme/ColorScheme.kt | 70 ++++ .../java/com/example/jetcaster/theme/Type.kt | 36 +- .../example/jetcaster/theme/WearAppTheme.kt | 8 +- .../jetcaster/ui/JetcasterNavController.kt | 81 +--- .../jetcaster/ui/components/MediaContent.kt | 93 ++++- .../ui/components/PlaceholderButton.kt | 111 ++++++ .../jetcaster/ui/components/PlayIconShape.kt | 27 ++ .../ui/components/SettingsButtons.kt | 2 - .../jetcaster/ui/episode/EpisodeScreen.kt | 296 +++++++++----- .../jetcaster/ui/episode/EpisodeViewModel.kt | 5 +- .../latest_episodes/LatestEpisodesScreen.kt | 210 +++++----- .../jetcaster/ui/library/LibraryScreen.kt | 361 ++++++++++++------ .../ui/podcast/PodcastDetailsScreen.kt | 210 ++++++---- .../ui/podcast/PodcastDetailsViewModel.kt | 5 +- .../jetcaster/ui/podcasts/PodcastsScreen.kt | 214 +++++++---- .../example/jetcaster/ui/queue/QueueScreen.kt | 232 +++++------ .../wear/src/main/res/drawable/delete.xml | 19 + .../wear/src/main/res/drawable/music.xml | 19 + Jetcaster/wear/src/main/res/drawable/play.xml | 19 + 24 files changed, 1331 insertions(+), 757 deletions(-) delete mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt create mode 100644 Jetcaster/wear/src/main/res/drawable/delete.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/music.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/play.xml diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index c04ecb4911..dc1fb6c01b 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -24,7 +24,8 @@ androidx-test-ext-junit = "1.2.1" androidx-test-ext-truth = "1.6.0" androidx-tv-foundation = "1.0.0-alpha12" androidx-tv-material = "1.0.0" -androidx-wear-compose = "1.4.1" +androidx-wear-compose-material3 = "1.0.0-alpha35" +androidx-wear-compose = "1.5.0-alpha12" androidx-window = "1.3.0" androidxHiltNavigationCompose = "1.2.0" androix-test-uiautomator = "2.3.0" @@ -36,7 +37,7 @@ google-maps = "19.2.0" gradle-versions = "0.52.0" hilt = "2.56.2" hiltExt = "1.2.0" -horologist = "0.6.23" +horologist = "0.7.11-alpha" jdkDesugar = "2.1.5" junit = "4.13.2" kotlin = "2.1.20" @@ -58,6 +59,7 @@ spotless = "7.0.3" # @keep targetSdk = "33" version-catalog-update = "1.0.0" +material3Android = "1.3.1" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -122,7 +124,7 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } -androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" } +androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material3", version.ref = "androidx-wear-compose-material3" } androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" } androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } @@ -137,6 +139,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } +horologist-audio-ui-model = { module = "com.google.android.horologist:horologist-audio-ui-model", version.ref = "horologist" } horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } @@ -144,6 +147,7 @@ horologist-compose-tools = { module = "com.google.android.horologist:horologist- horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } +horologist-media-ui-model = { module = "com.google.android.horologist:horologist-media-ui-model", version.ref = "horologist" } horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } @@ -162,6 +166,7 @@ rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } androidx-media3-session = {module = "androidx.media3:media3-session",version.ref = "media3"} androidx-media3-exoplayer = {module = "androidx.media3:media3-exoplayer", version.ref = "media3"} +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 67b9975aa6..9cf3c1b58f 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -20,6 +20,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -74,7 +75,6 @@ android { dependencies { - def composeBom = platform(libs.androidx.compose.bom) // General compose dependencies @@ -90,6 +90,9 @@ dependencies { // https://issuetracker.google.com/issues/new?component=1077552&template=1598429&pli=1 implementation libs.androidx.wear.compose.material + // For using the phone Typography + implementation libs.androidx.material3.android + implementation(libs.kotlinx.collections.immutable) // Foundation is additive, so you can use the mobile version in your Wear OS app. @@ -104,7 +107,9 @@ dependencies { //Horologist Media toolkit implementation libs.horologist.media.ui + implementation libs.horologist.media.ui.model implementation libs.horologist.audio.ui + implementation libs.horologist.audio.ui.model implementation libs.horologist.media.data implementation libs.horologist.images.coil @@ -140,9 +145,7 @@ dependencies { testImplementation libs.roborazzi testImplementation libs.roborazzi.compose testImplementation libs.roborazzi.rule - testImplementation(libs.horologist.roboscreenshots) { - exclude(group: "com.github.QuickBirdEng.kotlin-snapshot-testing") - } + testImplementation(libs.horologist.roboscreenshots) androidTestImplementation libs.androidx.test.ext.junit androidTestImplementation libs.androidx.test.espresso.core diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index e567f8e4f0..faca6663b1 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -29,7 +29,6 @@ class MainActivity : ComponentActivity() { lateinit var navController: NavHostController override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() super.onCreate(savedInstanceState) setContent { diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index 67014e02bf..a3b22c616a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -18,7 +18,6 @@ package com.example.jetcaster import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -28,6 +27,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable +import androidx.wear.compose.foundation.pager.rememberPagerState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.example.jetcaster.theme.WearAppTheme import com.example.jetcaster.ui.Episode @@ -49,8 +52,6 @@ import com.example.jetcaster.ui.podcasts.PodcastsScreen import com.example.jetcaster.ui.queue.QueueScreen import com.google.android.horologist.audio.ui.VolumeScreen import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.compose.layout.AppScaffold -import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume import com.google.android.horologist.media.ui.navigation.NavigationScreens @@ -64,7 +65,7 @@ fun WearApp(navController: NavHostController) { WearAppTheme { AppScaffold { SwipeDismissableNavHost( - startDestination = NavigationScreens.Player.playerDestination(), + startDestination = NavigationScreens.Player.navRoute, navController = navController, modifier = Modifier.background(Color.Transparent), state = navHostState, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt deleted file mode 100644 index cbec73b344..0000000000 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2021 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.theme - -import androidx.wear.compose.material.Colors -import com.example.jetcaster.designsystem.theme.errorDark -import com.example.jetcaster.designsystem.theme.onErrorDark -import com.example.jetcaster.designsystem.theme.onPrimaryDark -import com.example.jetcaster.designsystem.theme.onSecondaryDark -import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast -import com.example.jetcaster.designsystem.theme.primaryDark -import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast -import com.example.jetcaster.designsystem.theme.secondaryDark - -internal val wearColorPalette: Colors = Colors( - primary = primaryDark, - primaryVariant = primaryContainerDarkMediumContrast, - secondary = secondaryDark, - secondaryVariant = secondaryContainerDarkMediumContrast, - error = errorDark, - onPrimary = onPrimaryDark, - onSecondary = onSecondaryDark, - onError = onErrorDark, -) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt new file mode 100644 index 0000000000..474a6798a5 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2021 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.theme + +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material3.ColorScheme +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.onBackgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryDark + +internal val wearColorPalette: ColorScheme = ColorScheme( + primary = primaryDark, + onPrimary = Color(0xFF542104), + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDark, + onBackground = onBackgroundDarkMediumContrast, + onSurface = onSurfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDark, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + outline = outlineDark, + outlineVariant = outlineVariantDark, +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt index 39eecfb363..92b108f1e4 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt @@ -16,29 +16,21 @@ package com.example.jetcaster.theme -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import androidx.wear.compose.material.Typography -import com.example.jetcaster.designsystem.theme.Montserrat +import androidx.wear.compose.material3.Typography +import com.example.jetcaster.designsystem.theme.JetcasterTypography // Set of Material typography styles to start with val Typography = Typography( - body1 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - ), - /* Other default text styles to override - button = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.W500, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp - ) - */ + displayLarge = JetcasterTypography.displayLarge, + displayMedium = JetcasterTypography.displayMedium, + displaySmall = JetcasterTypography.displaySmall, + titleLarge = JetcasterTypography.titleLarge, + titleMedium = JetcasterTypography.titleMedium, + titleSmall = JetcasterTypography.titleSmall, + labelLarge = JetcasterTypography.labelLarge, + labelMedium = JetcasterTypography.labelMedium, + labelSmall = JetcasterTypography.labelSmall, + bodyLarge = JetcasterTypography.bodyLarge, + bodyMedium = JetcasterTypography.bodyMedium, + bodySmall = JetcasterTypography.bodySmall, ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt index b8411cdd27..2d93dd1b9a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt @@ -17,15 +17,15 @@ package com.example.jetcaster.theme import androidx.compose.runtime.Composable -import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material3.MaterialTheme @Composable fun WearAppTheme(content: @Composable () -> Unit) { MaterialTheme( - colors = wearColorPalette, + colorScheme = wearColorPalette, typography = Typography, // For shapes, we generally recommend using the default Material Wear shapes which are - // optimized for round and non-round devices. - content = content, + // optimized for round devices. + content = content ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt index 673eb527e2..da9f203c10 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -16,77 +16,20 @@ package com.example.jetcaster.ui -import android.net.Uri -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavController -import androidx.navigation.NavType -import androidx.navigation.navArgument -import com.google.android.horologist.media.ui.navigation.NavigationScreens +import com.google.android.horologist.media.ui.navigation.NavigationScreen +import kotlinx.serialization.Serializable -/** - * NavController extensions that links to the screens of the Jetcaster app. - */ -public object JetcasterNavController { - - public fun NavController.navigateToYourPodcast() { - navigate(YourPodcasts.destination()) - } - - public fun NavController.navigateToLatestEpisode() { - navigate(LatestEpisodes.destination()) - } - - public fun NavController.navigateToPodcastDetails(podcastUri: String) { - navigate(PodcastDetails.destination(podcastUri)) - } - - public fun NavController.navigateToUpNext() { - navigate(UpNext.destination()) - } - - public fun NavController.navigateToEpisode(episodeUri: String) { - navigate(Episode.destination(episodeUri)) - } -} - -public object YourPodcasts : NavigationScreens("yourPodcasts") { - public fun destination(): String = navRoute -} - -public object LatestEpisodes : NavigationScreens("latestEpisodes") { - public fun destination(): String = navRoute -} - -public object PodcastDetails : NavigationScreens("podcast?podcastUri={podcastUri}") { - public const val PODCAST_URI: String = "podcastUri" - public fun destination(podcastUri: String): String { - val encodedUri = Uri.encode(podcastUri) - return "podcast?$PODCAST_URI=$encodedUri" - } +@Serializable +data object YourPodcasts : NavigationScreen - override val arguments: List - get() = listOf( - navArgument(PODCAST_URI) { - type = NavType.StringType - }, - ) -} +@Serializable +data object LatestEpisodes : NavigationScreen -public object Episode : NavigationScreens("episode?episodeUri={episodeUri}") { - public const val EPISODE_URI: String = "episodeUri" - public fun destination(episodeUri: String): String { - val encodedUri = Uri.encode(episodeUri) - return "episode?$EPISODE_URI=$encodedUri" - } +@Serializable +data class PodcastDetails(val podcastUri: String) : NavigationScreen - override val arguments: List - get() = listOf( - navArgument(EPISODE_URI) { - type = NavType.StringType - }, - ) -} +@Serializable +data class Episode(val episodeUri: String) : NavigationScreen -public object UpNext : NavigationScreens("upNext") { - public fun destination(): String = navRoute -} +@Serializable +data object UpNext : NavigationScreen diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt index cf5e6aa4a7..e0209c74bf 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt @@ -16,22 +16,41 @@ package com.example.jetcaster.ui.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.wear.compose.material.ChipDefaults +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.player.model.PlayerEpisode -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.images.coil.CoilPaintable +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @Composable fun MediaContent( episode: PlayerEpisode, - episodeArtworkPlaceholder: Painter?, + episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), onItemClick: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, ) { @@ -52,17 +71,67 @@ fun MediaContent( else -> MediumDateFormatter.format(episode.published) } - Chip( - label = mediaTitle, + FilledTonalButton( + label = { + Text( + mediaTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, onClick = { onItemClick(episode) }, - secondaryLabel = secondaryLabel, - icon = CoilPaintable(episode.podcastImageUrl, episodeArtworkPlaceholder), - largeIcon = true, - colors = ChipDefaults.secondaryChipColors(), - modifier = modifier, + secondaryLabel = { + Text( + secondaryLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + AsyncImage( + model = episode.podcastImageUrl, + contentDescription = mediaTitle, + error = episodeArtworkPlaceholder, + placeholder = episodeArtworkPlaceholder, + contentScale = ContentScale.Crop, + modifier = Modifier + .size( + ButtonDefaults.LargeIconSize + ) + .clip(CircleShape) + + ) + }, + modifier = modifier.fillMaxWidth() ) } -public val MediumDateFormatter: DateTimeFormatter by lazy { +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun MediaContentPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + AppScaffold { + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.Button + ) + + ScreenScaffold(contentPadding = contentPadding) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + ) { + MediaContent( + episode, onItemClick = { null } + ) + } + } + } +} + +val MediumDateFormatter: DateTimeFormatter by lazy { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt new file mode 100644 index 0000000000..92f78be6a5 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.PlaceholderDefaults +import androidx.wear.compose.material3.PlaceholderState +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState +import com.google.android.horologist.annotations.ExperimentalHorologistApi + +/** + * A placeholder chip to be displayed while the contents of the [Button] is being loaded. + */ +@ExperimentalHorologistApi +@Composable +fun PlaceholderButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + placeholderState: PlaceholderState = rememberPlaceholderState { false }, + secondaryLabel: Boolean = true, + icon: Boolean = true +) { + var labelText by remember { mutableStateOf("") } + var imageVector: ImageVector? by remember { mutableStateOf(null) } + val buttonPlaceholderState = rememberPlaceholderState { + labelText.isNotEmpty() && imageVector != null + } + FilledTonalButton( + onClick = { onClick }, + enabled = true, + label = { + Column { + Box( + modifier = modifier + .padding(end = 10.dp) + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + .height(12.dp) + .placeholder(placeholderState), + ) + Spacer(Modifier.size(8.dp)) + } + }, + secondaryLabel = if (secondaryLabel) { + { + Box( + modifier = modifier + .fillMaxWidth() + .padding(end = 30.dp) + .clip(RoundedCornerShape(12.dp)) + .height(12.dp) + .placeholder(placeholderState), + ) + } + } else { + null + }, + icon = if (icon) { + { + Box( + modifier = + modifier.size(ButtonDefaults.IconSize).placeholder(buttonPlaceholderState) + ) + } + } else { + null + }, + colors = + PlaceholderDefaults.placeholderButtonColors( + originalButtonColors = ButtonDefaults.buttonColors(), + placeholderState = buttonPlaceholderState + ), + modifier = modifier.fillMaxWidth() + .placeholderShimmer(buttonPlaceholderState) + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt new file mode 100644 index 0000000000..6d86d4a235 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.IconButtonShapes + +@Composable +fun PlayIconShape(): IconButtonShapes { + return IconButtonShapes(RoundedCornerShape(16.dp)) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index 86684701ca..ef40487280 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -31,7 +31,6 @@ import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton import com.google.android.horologist.audio.ui.components.actions.SettingsButton -import com.google.android.horologist.compose.material.IconRtlMode /** * Settings buttons for the Jetcaster media app. @@ -90,7 +89,6 @@ fun PlaybackSpeedButton( ImageVector.vectorResource(R.drawable.speed_2x) } }, - iconRtlMode = IconRtlMode.Mirrored, contentDescription = stringResource(R.string.change_playback_speed_content_description), ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt index 02b68ff9d1..e7f44f2efa 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt @@ -17,46 +17,50 @@ package com.example.jetcaster.ui.episode import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd -import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.LocalContentColor -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.FilledIconButton +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.LocalContentColor +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R -import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.core.player.model.toPlayerEpisode import com.example.jetcaster.designsystem.component.HtmlTextContainer import com.example.jetcaster.ui.components.MediumDateFormatter -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.example.jetcaster.ui.components.PlaceholderButton +import com.example.jetcaster.ui.components.PlayIconShape +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.compose.material.Button -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.media.ui.screens.entity.EntityScreen +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding @Composable fun EpisodeScreen( @@ -86,124 +90,156 @@ fun EpisodeScreen( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) + + val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, - modifier = modifier, - ) { + contentPadding = contentPadding, + modifier = modifier + ) { contentPadding -> when (uiState) { is EpisodeScreenState.Loaded -> { val title = uiState.episode.episode.title - EntityScreen( - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - }, - buttonsContent = { - LoadedButtonsContent( - episode = uiState.episode, - onPlayButtonClick = onPlayButtonClick, - onPlayEpisode = onPlayEpisode, - onAddToQueue = onAddToQueue, - ) - }, - content = { - episodeInfoContent(episode = uiState.episode) - }, - + EpisodeScreenLoaded( + title = title, + episode = uiState.episode.toPlayerEpisode(), + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode, + onAddToQueue = onAddToQueue, + columnState = columnState, + contentPadding = contentPadding ) } EpisodeScreenState.Empty -> { AlertDialog( - showDialog = true, - onDismiss = { onDismiss }, - message = stringResource(R.string.episode_info_not_available), + visible = true, + onDismissRequest = { onDismiss }, + title = { stringResource(R.string.episode_info_not_available) } ) } EpisodeScreenState.Loading -> { - LoadingScreen() + EpisodeScreenLoading(columnState, contentPadding, modifier) + } + } + } +} + +@Composable +fun EpisodeScreenLoaded( + title: String, + episode: PlayerEpisode, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + onAddToQueue: (PlayerEpisode) -> Unit, + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( + modifier = modifier, + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) } } + item { + LoadedButtonsContent( + episode = episode, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode, + onAddToQueue = onAddToQueue + ) + } + episodeInfoContent(episode = episode) } } -@OptIn(ExperimentalHorologistApi::class) @Composable fun LoadedButtonsContent( - episode: EpisodeToPodcast, + episode: PlayerEpisode, onPlayButtonClick: () -> Unit, onPlayEpisode: (PlayerEpisode) -> Unit, onAddToQueue: (PlayerEpisode) -> Unit, enabled: Boolean = true, + modifier: Modifier = Modifier ) { Row( - modifier = Modifier + modifier = modifier .padding(bottom = 16.dp) .height(52.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), ) { - Button( - imageVector = Icons.Outlined.PlayArrow, - contentDescription = stringResource(id = R.string.button_play_content_description), + FilledIconButton( onClick = { onPlayButtonClick() - onPlayEpisode(episode.toPlayerEpisode()) + onPlayEpisode(episode) }, - enabled = enabled, modifier = Modifier .weight(weight = 0.3F, fill = false), - ) - - Button( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(id = R.string.add_to_queue_content_description), - onClick = { onAddToQueue(episode.toPlayerEpisode()) }, enabled = enabled, + shapes = PlayIconShape() + ) { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + } + + FilledIconButton( + onClick = { onAddToQueue(episode) }, modifier = Modifier .weight(weight = 0.3F, fill = false), - ) + enabled = enabled + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(id = R.string.add_to_queue_content_description) + ) + } } } -@OptIn(ExperimentalWearMaterialApi::class) @Composable -fun LoadingScreen() { - EntityScreen( - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { +fun EpisodeScreenLoading( + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( + modifier = modifier, + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { Text(text = stringResource(R.string.loading)) } - }, - buttonsContent = { + } + item { LoadingButtonsContent() - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - }, - ) + } + items(count = 2) { + PlaceholderButton() + } + } } -@OptIn(ExperimentalHorologistApi::class) @Composable fun LoadingButtonsContent() { Row( @@ -214,31 +250,39 @@ fun LoadingButtonsContent() { horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), ) { - Button( - imageVector = Icons.Outlined.PlayArrow, - contentDescription = stringResource(id = R.string.button_play_content_description), - onClick = {}, - enabled = false, + FilledIconButton( + onClick = { + }, modifier = Modifier .weight(weight = 0.3F, fill = false), - ) - - Button( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(id = R.string.add_to_queue_content_description), - onClick = {}, enabled = false, + shapes = PlayIconShape() + ) { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + } + + FilledIconButton( + onClick = { }, modifier = Modifier .weight(weight = 0.3F, fill = false), - ) + enabled = false + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(id = R.string.add_to_queue_content_description) + ) + } } } -private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { - val author = episode.episode.author - val duration = episode.episode.duration - val published = episode.episode.published - val summary = episode.episode.summary +private fun TransformingLazyColumnScope.episodeInfoContent(episode: PlayerEpisode) { + val author = episode.author + val duration = episode.duration + val published = episode.published + val summary = episode.summary if (!author.isNullOrEmpty()) { item { @@ -246,7 +290,7 @@ private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { text = author, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium ) } } @@ -268,7 +312,7 @@ private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { }, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodySmall, modifier = Modifier .padding(horizontal = 8.dp), ) @@ -279,7 +323,7 @@ private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { HtmlTextContainer(text = summary) { Text( text = it, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current, modifier = Modifier.listTextPadding(), ) @@ -287,3 +331,43 @@ private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { } } } + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun EpisodeScreenLoadingPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button + ) + + val columnState = rememberTransformingLazyColumnState() + EpisodeScreenLoading(columnState, contentPadding) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun EpisodeScreenLoadedPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button + ) + + EpisodeScreenLoaded( + title = episode.title, + episode = episode, + onPlayButtonClick = { }, + onPlayEpisode = { }, + onAddToQueue = { }, + columnState = columnState, + contentPadding = contentPadding + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt index 7a15b622b3..da4357873a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt @@ -36,6 +36,7 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.player.EpisodePlayer @@ -61,9 +62,7 @@ class EpisodeViewModel @Inject constructor( ) : ViewModel() { private val episodeUri: String = - savedStateHandle.get(Episode.EPISODE_URI).let { - Uri.decode(it) - } + savedStateHandle.toRoute().episodeUri private val episodeFlow = if (episodeUri != null) { episodeStore.episodeAndPodcastWithUri(episodeUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt index 4cd2952505..e730859959 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt @@ -16,41 +16,38 @@ package com.example.jetcaster.ui.latest_episodes -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewEpisodes -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import com.google.android.horologist.images.base.util.rememberVectorPainter -import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun LatestEpisodesScreen( onPlayButtonClick: () -> Unit, @@ -78,16 +75,17 @@ fun LatestEpisodeScreen( onPlayEpisode: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) + + val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, - modifier = modifier, - ) { + contentPadding = contentPadding, + modifier = modifier + ) { contentPadding -> when (uiState) { is LatestEpisodeScreenState.Loaded -> { LatestEpisodesScreen( @@ -95,44 +93,55 @@ fun LatestEpisodeScreen( onPlayButtonClick = onPlayButtonClick, onPlayEpisode = onPlayEpisode, onPlayEpisodes = onPlayEpisodes, - modifier = modifier, + contentPadding = contentPadding, + scrollState = columnState, + modifier = modifier ) } is LatestEpisodeScreenState.Empty -> { AlertDialog( - showDialog = true, - onDismiss = onDismiss, - message = stringResource(R.string.podcasts_no_episode_podcasts), + visible = true, + onDismissRequest = onDismiss, + title = { stringResource(R.string.podcasts_no_episode_podcasts) }, ) } is LatestEpisodeScreenState.Loading -> { LatestEpisodesScreenLoading( - modifier = modifier, + contentPadding = contentPadding, + scrollState = columnState, + modifier = modifier ) } } } } -@OptIn(ExperimentalHorologistApi::class) @Composable fun ButtonsContent( episodes: List, onPlayButtonClick: () -> Unit, onPlayEpisodes: (List) -> Unit, - modifier: Modifier = Modifier, + enabled: Boolean = true, + modifier: Modifier = Modifier ) { - Chip( - label = stringResource(id = R.string.button_play_content_description), + Button( onClick = { onPlayButtonClick() onPlayEpisodes(episodes) }, - modifier = modifier.padding(bottom = 16.dp), - icon = Icons.Outlined.PlayArrow.asPaintable(), - ) + enabled = enabled, + icon = { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + }, + modifier = modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.button_play_content_description)) + } } @Composable @@ -142,66 +151,77 @@ fun LatestEpisodesScreen( onPlayEpisode: (PlayerEpisode) -> Unit, onPlayEpisodes: (List) -> Unit, modifier: Modifier = Modifier, + contentPadding: PaddingValues, + scrollState: TransformingLazyColumnState ) { - EntityScreen( + TransformingLazyColumn( modifier = modifier, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { - Text(text = stringResource(id = R.string.latest_episodes)) - } - }, - content = { - items(count = episodeList.size) { index -> - MediaContent( - episode = episodeList[index], - episodeArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onItemClick = { - onPlayButtonClick() - onPlayEpisode(episodeList[index]) - }, - ) - } - }, - buttonsContent = { + state = scrollState, + contentPadding = contentPadding + ) { + item { + LatestEpisodesListHeader() + } + item { ButtonsContent( episodes = episodeList, onPlayButtonClick = onPlayButtonClick, onPlayEpisodes = onPlayEpisodes, ) - }, - ) + } + items(episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = painterResource(id = R.drawable.music), + onItemClick = { + onPlayButtonClick() + onPlayEpisode(episode) + } + ) + } + } +} + +@Composable +fun LatestEpisodesListHeader( + modifier: Modifier = Modifier +) { + ListHeader { + Text( + text = stringResource(id = R.string.latest_episodes), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + ) + } } -@OptIn(ExperimentalWearMaterialApi::class) @Composable -fun LatestEpisodesScreenLoading(modifier: Modifier = Modifier) { - EntityScreen( +fun LatestEpisodesScreenLoading( + contentPadding: PaddingValues, + scrollState: TransformingLazyColumnState, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( modifier = modifier, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { - Text(text = stringResource(id = R.string.latest_episodes)) - } - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - }, - buttonsContent = { + state = scrollState, + contentPadding = contentPadding + ) { + item { + LatestEpisodesListHeader() + } + item { ButtonsContent( episodes = emptyList(), onPlayButtonClick = { }, onPlayEpisodes = { }, + enabled = false ) - }, - ) + } + items(count = 2) { + PlaceholderButton() + } + } } @WearPreviewDevices @@ -211,17 +231,19 @@ fun LatestEpisodeScreenLoadedPreview( @PreviewParameter(WearPreviewEpisodes::class) episode: PlayerEpisode, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) + + val columnState = rememberTransformingLazyColumnState() LatestEpisodesScreen( episodeList = listOf(episode), onPlayButtonClick = { }, onPlayEpisode = { }, onPlayEpisodes = { }, + contentPadding = contentPadding, + scrollState = columnState ) } @@ -229,11 +251,11 @@ fun LatestEpisodeScreenLoadedPreview( @WearPreviewFontScales @Composable fun LatestEpisodeScreenLoadingPreview() { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) - LatestEpisodesScreenLoading() + + val columnState = rememberTransformingLazyColumnState() + LatestEpisodesScreenLoading(contentPadding, columnState) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt index 8b722a1496..68431a251e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt @@ -16,41 +16,56 @@ package com.example.jetcaster.ui.library +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import coil.compose.AsyncImage +import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.model.PlayerEpisode -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding -import com.google.android.horologist.compose.layout.ScalingLazyColumnState -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.images.base.paintable.DrawableResPaintable +import com.example.jetcaster.ui.components.PlaceholderButton +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.example.jetcaster.ui.preview.WearPreviewPodcasts +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import com.google.android.horologist.images.base.util.rememberVectorPainter import com.google.android.horologist.images.coil.CoilPaintable -import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun LibraryScreen( @@ -62,96 +77,102 @@ fun LibraryScreen( ) { val uiState by libraryScreenViewModel.uiState.collectAsState() - val columnState = rememberResponsiveColumnState( - contentPadding = ScalingLazyColumnDefaults.padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) - when (val s = uiState) { - is LibraryScreenUiState.Loading -> - LoadingScreen( - modifier = modifier, - ) - is LibraryScreenUiState.NoSubscribedPodcast -> - NoSubscribedPodcastScreen( - columnState = columnState, - modifier = modifier, - topPodcasts = s.topPodcasts, - onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed, - ) + val columnState = rememberTransformingLazyColumnState() + ScreenScaffold( + scrollState = columnState, + contentPadding = contentPadding, + modifier = modifier + ) { contentPadding -> + when (val s = uiState) { + is LibraryScreenUiState.Loading -> + LoadingScreen( + scrollState = columnState, + contentPadding = contentPadding, + modifier = modifier + ) - is LibraryScreenUiState.Ready -> - LibraryScreen( - columnState = columnState, - modifier = modifier, - onLatestEpisodeClick = onLatestEpisodeClick, - onYourPodcastClick = onYourPodcastClick, - onUpNextClick = onUpNextClick, - queue = s.queue, - ) + is LibraryScreenUiState.NoSubscribedPodcast -> + NoSubscribedPodcastScreen( + columnState = columnState, + contentPadding = contentPadding, + modifier = modifier, + topPodcasts = s.topPodcasts, + onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed + ) + + is LibraryScreenUiState.Ready -> + LibraryScreen( + columnState = columnState, + contentPadding = contentPadding, + modifier = modifier, + onLatestEpisodeClick = onLatestEpisodeClick, + onYourPodcastClick = onYourPodcastClick, + onUpNextClick = onUpNextClick, + queue = s.queue + ) + } } } -@OptIn(ExperimentalWearMaterialApi::class) @Composable -fun LoadingScreen(modifier: Modifier) { - EntityScreen( - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { +fun LoadingScreen( + modifier: Modifier, + scrollState: TransformingLazyColumnState, + contentPadding: PaddingValues, +) { + TransformingLazyColumn( + state = scrollState, contentPadding = contentPadding, + modifier = modifier + ) { + item { + ListHeader { Text(text = stringResource(R.string.loading)) } - }, - modifier = modifier, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - }, - ) + } + items(count = 2) { + PlaceholderButton() + } + } } -@OptIn(ExperimentalWearMaterialApi::class) @Composable fun NoSubscribedPodcastScreen( - columnState: ScalingLazyColumnState, + columnState: TransformingLazyColumnState, modifier: Modifier, topPodcasts: List, onTogglePodcastFollowed: (uri: String) -> Unit, + + contentPadding: PaddingValues ) { - ScreenScaffold(scrollState = columnState, modifier = modifier) { - ScalingLazyColumn(columnState = columnState) { - item { - ResponsiveListHeader( - modifier = modifier.listTextPadding(), - contentColor = MaterialTheme.colors.onSurface, - ) { - Text(stringResource(R.string.entity_no_featured_podcasts)) - } + TransformingLazyColumn( + state = columnState, contentPadding = contentPadding, + modifier = modifier + ) { + item { + ListHeader( + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Text(stringResource(R.string.entity_no_featured_podcasts)) } - if (topPodcasts.isNotEmpty()) { - items(topPodcasts.take(3)) { podcast -> - PodcastContent( - podcast = podcast, - downloadItemArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onClick = { - onTogglePodcastFollowed(podcast.uri) - }, - ) - } - } else { - item { - PlaceholderChip( - contentDescription = "", - colors = ChipDefaults.secondaryChipColors(), - ) - } + } + if (topPodcasts.isNotEmpty()) { + items(topPodcasts.take(3)) { podcast -> + PodcastContent( + podcast = podcast, + podcastArtworkPlaceholder = painterResource(id = R.drawable.music), + onClick = { + onTogglePodcastFollowed(podcast.uri) + }, + ) + } + } else { + item { + PlaceholderButton() } } } @@ -160,56 +181,115 @@ fun NoSubscribedPodcastScreen( @Composable private fun PodcastContent( podcast: PodcastInfo, - downloadItemArtworkPlaceholder: Painter?, onClick: () -> Unit, + podcastArtworkPlaceholder: Painter?, modifier: Modifier = Modifier, ) { val mediaTitle = podcast.title - Chip( - label = mediaTitle, - onClick = onClick, - modifier = modifier, - icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), - largeIcon = true, - colors = ChipDefaults.secondaryChipColors(), + FilledTonalButton( + label = { + Text( + mediaTitle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = { onClick }, + icon = { + AsyncImage( + model = podcast.imageUrl, + contentDescription = stringResource(R.string.latest_episodes), + contentScale = ContentScale.Crop, + error = podcastArtworkPlaceholder, + placeholder = podcastArtworkPlaceholder, + modifier = Modifier + .size( + ButtonDefaults.LargeIconSize + ) + .clip(CircleShape) + + ) + }, + modifier = modifier.fillMaxWidth() ) } +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastContentPreview(@PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo) { + AppScaffold { + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.Button + ) + + ScreenScaffold(contentPadding = contentPadding) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + ) { + PodcastContent( + podcast = podcasts, + podcastArtworkPlaceholder = painterResource(id = R.drawable.music), + onClick = {}, + + ) + } + } + } +} + @Composable fun LibraryScreen( - columnState: ScalingLazyColumnState, + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, modifier: Modifier, onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, queue: List, ) { - ScreenScaffold(scrollState = columnState, modifier = modifier) { - ScalingLazyColumn(columnState = columnState) { + ScreenScaffold( + scrollState = columnState, + contentPadding = contentPadding, + modifier = modifier + ) { + TransformingLazyColumn(state = columnState, contentPadding = contentPadding) { item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + ListHeader { Text(stringResource(R.string.home_library)) } } item { - Chip( - label = stringResource(R.string.latest_episodes), - onClick = onLatestEpisodeClick, - icon = DrawableResPaintable(R.drawable.new_releases), - colors = ChipDefaults.secondaryChipColors(), + FilledTonalButton( + label = { Text(stringResource(R.string.latest_episodes)) }, + onClick = { onLatestEpisodeClick() }, + icon = { + IconWithBackground( + R.drawable.new_releases, + stringResource(R.string.latest_episodes) + ) + }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = modifier.fillMaxWidth() ) } item { - Chip( - label = stringResource(R.string.podcasts), - onClick = onYourPodcastClick, - icon = DrawableResPaintable(R.drawable.podcast), - colors = ChipDefaults.secondaryChipColors(), + FilledTonalButton( + label = { Text(stringResource(R.string.podcasts)) }, + onClick = { onYourPodcastClick() }, + icon = { + IconWithBackground(R.drawable.podcast, stringResource(R.string.podcasts)) + }, + modifier = modifier.fillMaxWidth() ) } item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + ListHeader { Text(stringResource(R.string.queue)) } } @@ -217,11 +297,13 @@ fun LibraryScreen( if (queue.isEmpty()) { QueueEmpty() } else { - Chip( - label = stringResource(R.string.up_next), - onClick = onUpNextClick, - icon = DrawableResPaintable(R.drawable.up_next), - colors = ChipDefaults.secondaryChipColors(), + FilledTonalButton( + label = { Text(stringResource(R.string.up_next)) }, + onClick = { onUpNextClick() }, + icon = { + IconWithBackground(R.drawable.up_next, stringResource(R.string.up_next)) + }, + modifier = modifier.fillMaxWidth() ) } } @@ -229,12 +311,53 @@ fun LibraryScreen( } } +@Composable +private fun IconWithBackground(resource: Int, contentDescription: String) { + Box( + modifier = Modifier + .size(ButtonDefaults.LargeIconSize) + .background( + MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = resource), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(ButtonDefaults.SmallIconSize) + ) + } +} + @Composable private fun QueueEmpty() { Text( text = stringResource(id = R.string.add_episode_to_queue), modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), textAlign = TextAlign.Center, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodySmall, + + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun LibraryScreenPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + LibraryScreen( + columnState = rememberTransformingLazyColumnState(), + contentPadding = PaddingValues(), + modifier = Modifier, + onLatestEpisodeClick = {}, + onYourPodcastClick = {}, + onUpNextClick = {}, + queue = listOf( + episode + ) ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index e1b2e03f6e..a4d25b69c2 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -16,23 +16,27 @@ package com.example.jetcaster.ui.podcast -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R @@ -40,20 +44,12 @@ import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import com.google.android.horologist.images.base.util.rememberVectorPainter -import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun PodcastDetailsScreen( onPlayButtonClick: () -> Unit, @@ -74,7 +70,6 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen ) } -@OptIn(ExperimentalWearMaterialApi::class) @Composable fun PodcastDetailsScreen( uiState: PodcastDetailsScreenState, @@ -84,95 +79,142 @@ fun PodcastDetailsScreen( onPlayEpisode: (List) -> Unit, onDismiss: () -> Unit, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) + + val columnState = rememberTransformingLazyColumnState() + ScreenScaffold( scrollState = columnState, - modifier = modifier, + contentPadding = contentPadding, + modifier = modifier ) { when (uiState) { is PodcastDetailsScreenState.Loaded -> { - EntityScreen( - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { - Text(text = uiState.podcast.title) - } - }, - buttonsContent = { - ButtonsContent( - episodes = uiState.episodeList, - onPlayButtonClick = onPlayButtonClick, - onPlayEpisode = onPlayEpisode, - ) - }, - content = { - items(uiState.episodeList) { episode -> - MediaContent( - episode = episode, - episodeArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onEpisodeItemClick, - ) - } - }, + PodcastDetailScreenLoaded( + uiState.episodeList, + uiState.podcast.title, + onPlayButtonClick, + onPlayEpisode, + onEpisodeItemClick, + columnState, + contentPadding, + modifier ) } PodcastDetailsScreenState.Empty -> { AlertDialog( - showDialog = true, - onDismiss = { onDismiss }, - message = stringResource(R.string.podcasts_no_episode_podcasts), + visible = true, + onDismissRequest = onDismiss, + title = { stringResource(R.string.podcasts_no_episode_podcasts) }, ) } PodcastDetailsScreenState.Loading -> { - EntityScreen( - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { - Text(text = stringResource(id = R.string.loading)) - } - }, - buttonsContent = { - ButtonsContent( - episodes = emptyList(), - onPlayButtonClick = { }, - onPlayEpisode = { }, - ) - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - }, + PodcastDetailScreenLoading(columnState, contentPadding, modifier) + } + } + } +} + +@Composable +fun PodcastDetailScreenLoaded( + episodeList: List, + title: String, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (List) -> Unit, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( + modifier = modifier, + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { + Text( + text = title, maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = modifier ) } } + item { + ButtonsContent( + episodes = episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode + ) + } + items(episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = painterResource(id = R.drawable.music), + onEpisodeItemClick + ) + } } } -@OptIn(ExperimentalHorologistApi::class) @Composable -fun ButtonsContent(episodes: List, onPlayButtonClick: () -> Unit, onPlayEpisode: (List) -> Unit) { +fun PodcastDetailScreenLoading( + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( + modifier = modifier, + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { + Text(text = stringResource(id = R.string.loading)) + } + } + item { + ButtonsContent( + episodes = emptyList(), + onPlayButtonClick = {}, + onPlayEpisode = {}, + enabled = false + ) + } + items(count = 2) { + PlaceholderButton() + } + } +} +@Composable +fun ButtonsContent( + episodes: List, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (List) -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { - Chip( - label = stringResource(id = R.string.button_play_content_description), + Button( onClick = { onPlayButtonClick() onPlayEpisode(episodes) }, - modifier = Modifier.padding(bottom = 16.dp), - icon = Icons.Outlined.PlayArrow.asPaintable(), - ) + enabled = enabled, + icon = { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + }, + modifier = modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.button_play_content_description)) + } } @ExperimentalHorologistApi diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 8ef4f321d9..81cccd8c06 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -36,6 +36,7 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.model.asExternalModel @@ -65,9 +66,7 @@ class PodcastDetailsViewModel @Inject constructor( ) : ViewModel() { private val podcastUri: String = - savedStateHandle.get(PodcastDetails.PODCAST_URI).let { - Uri.decode(it) - } + Uri.decode(savedStateHandle.toRoute().podcastUri) private val podcastFlow = if (podcastUri != null) { podcastStore.podcastWithExtraInfo(podcastUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt index 2c4a79dab1..3d270ec8b2 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt @@ -16,38 +16,45 @@ package com.example.jetcaster.ui.podcasts -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewPodcasts -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import com.google.android.horologist.images.base.util.rememberVectorPainter -import com.google.android.horologist.images.coil.CoilPaintable -import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader -import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun PodcastsScreen( @@ -79,7 +86,6 @@ fun PodcastsScreen( ) } -@ExperimentalHorologistApi @Composable fun PodcastsScreen( podcastsScreenState: PodcastsScreenState, @@ -88,92 +94,121 @@ fun PodcastsScreen( modifier: Modifier = Modifier, ) { - val columnState = rememberResponsiveColumnState() + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button + ) ScreenScaffold( scrollState = columnState, - modifier = modifier, + contentPadding = contentPadding, + modifier = modifier ) { when (podcastsScreenState) { is PodcastsScreenState.Loaded -> PodcastScreenLoaded( podcastList = podcastsScreenState.podcastList, onPodcastsItemClick = onPodcastsItemClick, + columnState = columnState, + contentPadding = contentPadding, + modifier = modifier ) PodcastsScreenState.Empty -> PodcastScreenEmpty(onDismiss) PodcastsScreenState.Loading -> - PodcastScreenLoading() + PodcastScreenLoading( + columnState = columnState, + contentPadding = contentPadding, + modifier = modifier + ) } } } @Composable -fun PodcastScreenLoaded(podcastList: List, onPodcastsItemClick: (PodcastInfo) -> Unit, modifier: Modifier = Modifier) { - EntityScreen( +fun PodcastScreenLoaded( + podcastList: List, + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues, + columnState: TransformingLazyColumnState +) { + TransformingLazyColumn( modifier = modifier, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { Text(text = stringResource(id = R.string.podcasts)) } - }, - content = { - items(count = podcastList.size) { index -> - MediaContent( - podcast = podcastList[index], - downloadItemArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onPodcastsItemClick = onPodcastsItemClick, + } + items(count = podcastList.size) { + index -> + MediaContent( + podcast = podcastList[index], + onPodcastsItemClick = onPodcastsItemClick - ) - } - }, - ) + ) + } + } } @Composable fun PodcastScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { AlertDialog( - showDialog = true, - message = stringResource(R.string.podcasts_no_podcasts), - onDismiss = onDismiss, - modifier = modifier, + visible = true, + title = { Text(stringResource(R.string.podcasts_no_podcasts)) }, + onDismissRequest = { onDismiss }, + modifier = modifier ) } -@OptIn(ExperimentalWearMaterialApi::class) @Composable -fun PodcastScreenLoading(modifier: Modifier = Modifier) { - EntityScreen( +fun PodcastScreenLoading( + modifier: Modifier = Modifier, + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues +) { + + TransformingLazyColumn( modifier = modifier, - headerContent = { - DefaultEntityScreenHeader( - title = stringResource(R.string.podcasts), - ) - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.podcasts), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + ) } - }, - ) + } + + items(count = 2) { + PlaceholderButton() + } + } } @WearPreviewDevices @WearPreviewFontScales @Composable -fun PodcastScreenLoadedPreview(@PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo) { - val columnState = rememberResponsiveColumnState( - contentPadding = ScalingLazyColumnDefaults.padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), +fun PodcastScreenLoadedPreview( + @PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo +) { + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) PodcastScreenLoaded( podcastList = listOf(podcasts), onPodcastsItemClick = {}, + contentPadding = contentPadding, + columnState = columnState ) } @@ -181,13 +216,12 @@ fun PodcastScreenLoadedPreview(@PreviewParameter(WearPreviewPodcasts::class) pod @WearPreviewFontScales @Composable fun PodcastScreenLoadingPreview() { - val columnState = rememberResponsiveColumnState( - contentPadding = ScalingLazyColumnDefaults.padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) - PodcastScreenLoading() + PodcastScreenLoading(columnState = columnState, contentPadding = contentPadding) } @WearPreviewDevices @@ -198,17 +232,41 @@ fun PodcastScreenEmptyPreview() { } @Composable -fun MediaContent(podcast: PodcastInfo, downloadItemArtworkPlaceholder: Painter?, onPodcastsItemClick: (PodcastInfo) -> Unit) { +fun MediaContent( + podcast: PodcastInfo, + episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { val mediaTitle = podcast.title - val secondaryLabel = podcast.author - Chip( - label = mediaTitle, + FilledTonalButton( + label = { + Text( + mediaTitle, maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start + ) + }, onClick = { onPodcastsItemClick(podcast) }, - secondaryLabel = secondaryLabel, - icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), - largeIcon = true, - colors = ChipDefaults.secondaryChipColors(), + secondaryLabel = { Text(secondaryLabel, maxLines = 1) }, + icon = { + AsyncImage( + model = podcast.imageUrl, + contentDescription = mediaTitle, + error = episodeArtworkPlaceholder, + placeholder = episodeArtworkPlaceholder, + contentScale = ContentScale.Crop, + modifier = modifier + .size( + ButtonDefaults.LargeIconSize + ) + .clip(CircleShape) + + ) + }, + modifier = Modifier.fillMaxWidth(), + ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt index 2a69c4be70..ddcd59dd6d 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt @@ -16,47 +16,45 @@ package com.example.jetcaster.ui.queue -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.ButtonGroup +import androidx.wear.compose.material3.FilledIconButton +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.components.PlaceholderButton +import com.example.jetcaster.ui.components.PlayIconShape import com.example.jetcaster.ui.preview.WearPreviewEpisodes -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.compose.material.Button -import com.google.android.horologist.compose.material.ListHeaderDefaults -import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding import com.google.android.horologist.images.base.util.rememberVectorPainter -import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader -import com.google.android.horologist.media.ui.screens.entity.EntityScreen @Composable fun QueueScreen( onPlayButtonClick: () -> Unit, @@ -88,16 +86,17 @@ fun QueueScreen( onDeleteQueueEpisodes: () -> Unit, onDismiss: () -> Unit, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) + + val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, - modifier = modifier, - ) { + contentPadding = contentPadding, + modifier = modifier + ) { contentPadding -> when (uiState) { is QueueScreenState.Loaded -> QueueScreenLoaded( episodeList = uiState.episodeList, @@ -105,8 +104,10 @@ fun QueueScreen( onPlayEpisodes = onPlayEpisodes, onDeleteQueueEpisodes = onDeleteQueueEpisodes, onEpisodeItemClick = onEpisodeItemClick, + columnState = columnState, + contentPadding = contentPadding ) - QueueScreenState.Loading -> QueueScreenLoading() + QueueScreenState.Loading -> QueueScreenLoading(columnState, contentPadding) QueueScreenState.Empty -> QueueScreenEmpty(onDismiss) } } @@ -119,51 +120,58 @@ fun QueueScreenLoaded( onPlayEpisodes: (List) -> Unit, onDeleteQueueEpisodes: () -> Unit, onEpisodeItemClick: (PlayerEpisode) -> Unit, - modifier: Modifier = Modifier, + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier ) { - EntityScreen( + TransformingLazyColumn( modifier = modifier, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding(), - ) { + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { Text(text = stringResource(R.string.queue)) } - }, - buttonsContent = { + } + item { ButtonsContent( episodes = episodeList, onPlayButtonClick = onPlayButtonClick, onPlayEpisodes = onPlayEpisodes, onDeleteQueueEpisodes = onDeleteQueueEpisodes, ) - }, - content = { - items(episodeList) { episode -> - MediaContent( - episode = episode, - episodeArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onItemClick = onEpisodeItemClick, - ) - } - }, - ) + } + items(episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = painterResource(id = R.drawable.music), + onItemClick = onEpisodeItemClick + ) + } + } } -@OptIn(ExperimentalWearMaterialApi::class) @Composable -fun QueueScreenLoading(modifier: Modifier = Modifier) { - EntityScreen( +fun QueueScreenLoading( + columnState: TransformingLazyColumnState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + TransformingLazyColumn( modifier = modifier, - headerContent = { - DefaultEntityScreenHeader( - title = stringResource(R.string.queue), - ) - }, - buttonsContent = { + state = columnState, + contentPadding = contentPadding + ) { + item { + ListHeader { + Text( + text = stringResource(R.string.queue), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + item { ButtonsContent( episodes = emptyList(), onPlayButtonClick = {}, @@ -171,27 +179,24 @@ fun QueueScreenLoading(modifier: Modifier = Modifier) { onDeleteQueueEpisodes = { }, enabled = false, ) - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - }, - ) + } + items(count = 2) { + PlaceholderButton() + } + } } @Composable fun QueueScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { AlertDialog( - showDialog = true, - onDismiss = onDismiss, - title = stringResource(R.string.display_nothing_in_queue), - message = stringResource(R.string.no_episodes_from_queue), - modifier = modifier, + visible = true, + onDismissRequest = onDismiss, + title = { stringResource(R.string.display_nothing_in_queue) }, + text = { stringResource(R.string.no_episodes_from_queue) }, + modifier = modifier ) } -@OptIn(ExperimentalHorologistApi::class) @Composable fun ButtonsContent( episodes: List, @@ -202,33 +207,39 @@ fun ButtonsContent( modifier: Modifier = Modifier, ) { - Row( - modifier = modifier - .padding(bottom = 16.dp) - .height(52.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - Button( - imageVector = Icons.Outlined.PlayArrow, - contentDescription = stringResource(id = R.string.button_play_content_description), - onClick = { - onPlayButtonClick() - onPlayEpisodes(episodes) - }, - modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = enabled, - ) - Button( - imageVector = Icons.Outlined.Delete, - contentDescription = - stringResource(id = R.string.button_delete_queue_content_description), - onClick = onDeleteQueueEpisodes, - modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = enabled, - ) + Box(modifier = modifier + .padding(bottom = 16.dp) + .height(52.dp), + contentAlignment = Alignment.Center) { + ButtonGroup(Modifier.fillMaxWidth()) { + FilledIconButton( + onClick = { + onPlayButtonClick() + onPlayEpisodes(episodes) + }, + modifier = modifier + .weight(weight = 0.7F), + enabled = enabled, + shapes = PlayIconShape() + ) { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + } + FilledIconButton( + onClick = onDeleteQueueEpisodes, + modifier = modifier + .weight(weight = 0.3F), + enabled = enabled + ) { + Icon( + painter = painterResource(id = R.drawable.delete), + contentDescription = + stringResource(id = R.string.button_delete_queue_content_description), + ) + } + } } } @@ -239,11 +250,10 @@ fun QueueScreenLoadedPreview( @PreviewParameter(WearPreviewEpisodes::class) episode: PlayerEpisode, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) QueueScreenLoaded( episodeList = listOf(episode), @@ -251,6 +261,8 @@ fun QueueScreenLoadedPreview( onPlayEpisodes = { }, onDeleteQueueEpisodes = { }, onEpisodeItemClick = { }, + columnState = columnState, + contentPadding = contentPadding ) } @@ -258,13 +270,11 @@ fun QueueScreenLoadedPreview( @WearPreviewFontScales @Composable fun QueueScreenLoadingPreview() { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button ) - QueueScreenLoading() + QueueScreenLoading(rememberTransformingLazyColumnState(), contentPadding) } @WearPreviewDevices diff --git a/Jetcaster/wear/src/main/res/drawable/delete.xml b/Jetcaster/wear/src/main/res/drawable/delete.xml new file mode 100644 index 0000000000..a17ed49cca --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/delete.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/music.xml b/Jetcaster/wear/src/main/res/drawable/music.xml new file mode 100644 index 0000000000..ae2b376f6e --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/music.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/play.xml b/Jetcaster/wear/src/main/res/drawable/play.xml new file mode 100644 index 0000000000..b11274e1c9 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/play.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file From 122457f60b9f24f45e530b2dfc2f8c677477d0ea Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Mon, 7 Apr 2025 16:11:20 +0100 Subject: [PATCH 2/4] Adds box to show AlertDialog Change-Id: Ic3b3b80082c0a34f1be4cdd2f2820a978b874b02 --- .../example/jetcaster/ui/episode/EpisodeScreen.kt | 13 +++++++++++++ .../example/jetcaster/ui/library/LibraryScreen.kt | 7 +------ .../com/example/jetcaster/ui/queue/QueueScreen.kt | 8 +++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt index e7f44f2efa..569abb9b75 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt @@ -332,6 +332,19 @@ private fun TransformingLazyColumnScope.episodeInfoContent(episode: PlayerEpisod } } +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun EpisodeScreenEmptyPreview() { + val uiState: EpisodeScreenState = EpisodeScreenState.Empty + EpisodeScreen( + uiState = uiState, + onPlayButtonClick = { }, + onPlayEpisode = { _ -> }, + onAddToQueue = { _ -> }, + onDismiss = {} + ) +} @WearPreviewDevices @WearPreviewFontScales @Composable diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt index 68431a251e..8d4433e8c6 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt @@ -29,10 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -55,7 +53,6 @@ import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import coil.compose.AsyncImage -import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.model.PlayerEpisode @@ -64,8 +61,6 @@ import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.example.jetcaster.ui.preview.WearPreviewPodcasts import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding -import com.google.android.horologist.images.base.util.rememberVectorPainter -import com.google.android.horologist.images.coil.CoilPaintable @Composable fun LibraryScreen( @@ -195,7 +190,7 @@ private fun PodcastContent( overflow = TextOverflow.Ellipsis, ) }, - onClick = { onClick }, + onClick = { onClick() }, icon = { AsyncImage( model = podcast.imageUrl, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt index ddcd59dd6d..8bc08ed80d 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -54,7 +53,6 @@ import com.example.jetcaster.ui.components.PlayIconShape import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding -import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun QueueScreen( onPlayButtonClick: () -> Unit, @@ -190,9 +188,9 @@ fun QueueScreenLoading( fun QueueScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { AlertDialog( visible = true, - onDismissRequest = onDismiss, - title = { stringResource(R.string.display_nothing_in_queue) }, - text = { stringResource(R.string.no_episodes_from_queue) }, + onDismissRequest = { onDismiss() }, + title = { Text(stringResource(R.string.display_nothing_in_queue)) }, + text = { Text(stringResource(R.string.no_episodes_from_queue)) }, modifier = modifier ) } From 09741435fc8e8e5ac104bde2bbff85c76d3dd724 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Wed, 14 May 2025 17:19:27 +0100 Subject: [PATCH 3/4] Update to latest Horologist Change-Id: Ie939e3538220769a76941b69c3c2253cb908e3fb --- Jetcaster/gradle/libs.versions.toml | 11 +- Jetcaster/wear/build.gradle | 9 +- .../java/com/example/jetcaster/WearApp.kt | 11 +- .../example/jetcaster/theme/ColorScheme.kt | 2 + .../jetcaster/ui/JetcasterNavController.kt | 84 ++++++- .../ui/components/PlaceholderButton.kt | 13 +- .../ui/components/SettingsButtons.kt | 41 ++-- .../jetcaster/ui/episode/EpisodeScreen.kt | 71 +++--- .../jetcaster/ui/episode/EpisodeViewModel.kt | 4 +- .../jetcaster/ui/library/LibraryScreen.kt | 21 +- .../jetcaster/ui/player/PlayerScreen.kt | 216 +++++++++++++----- .../ui/podcast/PodcastDetailsViewModel.kt | 4 +- .../example/jetcaster/ui/queue/QueueScreen.kt | 12 +- 13 files changed, 351 insertions(+), 148 deletions(-) diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index dc1fb6c01b..aa3c9bee90 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -24,8 +24,8 @@ androidx-test-ext-junit = "1.2.1" androidx-test-ext-truth = "1.6.0" androidx-tv-foundation = "1.0.0-alpha12" androidx-tv-material = "1.0.0" -androidx-wear-compose-material3 = "1.0.0-alpha35" -androidx-wear-compose = "1.5.0-alpha12" +androidx-wear-compose-material3 = "1.5.0-beta01" +androidx-wear-compose = "1.5.0-beta01" androidx-window = "1.3.0" androidxHiltNavigationCompose = "1.2.0" androix-test-uiautomator = "2.3.0" @@ -37,7 +37,8 @@ google-maps = "19.2.0" gradle-versions = "0.52.0" hilt = "2.56.2" hiltExt = "1.2.0" -horologist = "0.7.11-alpha" +horologist = "0.7.14-beta" +# @pin When updating to AGP 7.4.0-alpha10 and up we can update this https://developer.android.com/studio/write/java8-support#library-desugaring-versions jdkDesugar = "2.1.5" junit = "4.13.2" kotlin = "2.1.20" @@ -60,6 +61,7 @@ spotless = "7.0.3" targetSdk = "33" version-catalog-update = "1.0.0" material3Android = "1.3.1" +media3CommonKtx = "1.7.1" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -139,6 +141,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } +horologist-audio-uimaterial3 = { module = "com.google.android.horologist:horologist-audio-ui-material3", version.ref = "horologist" } horologist-audio-ui-model = { module = "com.google.android.horologist:horologist-audio-ui-model", version.ref = "horologist" } horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } @@ -147,6 +150,7 @@ horologist-compose-tools = { module = "com.google.android.horologist:horologist- horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } +horologist-media-uimaterial3 = { module = "com.google.android.horologist:horologist-media-ui-material3", version.ref = "horologist" } horologist-media-ui-model = { module = "com.google.android.horologist:horologist-media-ui-model", version.ref = "horologist" } horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } junit = { module = "junit:junit", version.ref = "junit" } @@ -167,6 +171,7 @@ rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } androidx-media3-session = {module = "androidx.media3:media3-session",version.ref = "media3"} androidx-media3-exoplayer = {module = "androidx.media3:media3-exoplayer", version.ref = "media3"} androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } +androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3CommonKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 9cf3c1b58f..e56e84f0ac 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -75,6 +75,7 @@ android { dependencies { + implementation libs.androidx.media3.common.ktx def composeBom = platform(libs.androidx.compose.bom) // General compose dependencies @@ -100,15 +101,19 @@ dependencies { implementation(libs.androidx.material.icons.core) implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui.compose) + implementation(libs.androidx.media3.session) + // Horologist for correct Compose layout - implementation libs.horologist.composables implementation libs.horologist.compose.layout - implementation libs.horologist.compose.material //Horologist Media toolkit implementation libs.horologist.media.ui + implementation libs.horologist.media.uimaterial3 implementation libs.horologist.media.ui.model implementation libs.horologist.audio.ui + implementation libs.horologist.audio.uimaterial3 implementation libs.horologist.audio.ui.model implementation libs.horologist.media.data implementation libs.horologist.images.coil diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index a3b22c616a..114f85842f 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -30,7 +30,6 @@ import androidx.wear.compose.navigation.composable import androidx.wear.compose.foundation.pager.rememberPagerState import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.ScreenScaffold -import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.example.jetcaster.theme.WearAppTheme import com.example.jetcaster.ui.Episode @@ -50,12 +49,12 @@ import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.podcast.PodcastDetailsScreen import com.example.jetcaster.ui.podcasts.PodcastsScreen import com.example.jetcaster.ui.queue.QueueScreen -import com.google.android.horologist.audio.ui.VolumeScreen +import com.google.android.horologist.audio.ui.material3.VolumeScreen import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume -import com.google.android.horologist.media.ui.navigation.NavigationScreens -import com.google.android.horologist.media.ui.screens.playerlibrarypager.PlayerLibraryPagerScreen +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.material3.navigation.NavigationScreens +import com.google.android.horologist.media.ui.material3.screens.playerlibrarypager.PlayerLibraryPagerScreen @Composable fun WearApp(navController: NavHostController) { diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt index 474a6798a5..01f9b51ab0 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt @@ -44,10 +44,12 @@ import com.example.jetcaster.designsystem.theme.tertiaryDark internal val wearColorPalette: ColorScheme = ColorScheme( primary = primaryDark, + primaryDim= primaryDark, onPrimary = Color(0xFF542104), primaryContainer = primaryContainerDark, onPrimaryContainer = onPrimaryContainerDark, secondary = secondaryDark, + secondaryDim = secondaryDark, onSecondary = onSecondaryDark, secondaryContainer = secondaryContainerDark, onSecondaryContainer = onSecondaryContainerDark, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt index da9f203c10..8dc0540c7c 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -16,20 +16,80 @@ package com.example.jetcaster.ui -import com.google.android.horologist.media.ui.navigation.NavigationScreen -import kotlinx.serialization.Serializable +import android.net.Uri +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.material3.navigation.NavigationScreens +import com.google.android.horologist.media.ui.material3.screens.playerlibrarypager.PlayerLibraryPagerScreen -@Serializable -data object YourPodcasts : NavigationScreen +/** + * NavController extensions that links to the screens of the Jetcaster app. + */ +public object JetcasterNavController { + + public fun NavController.navigateToYourPodcast() { + navigate(YourPodcasts.destination()) + } + + public fun NavController.navigateToLatestEpisode() { + navigate(LatestEpisodes.destination()) + } + + public fun NavController.navigateToPodcastDetails(podcastUri: String) { + navigate(PodcastDetails.destination(podcastUri)) + } + + public fun NavController.navigateToUpNext() { + navigate(UpNext.destination()) + } + + public fun NavController.navigateToEpisode(episodeUri: String) { + navigate(Episode.destination(episodeUri)) + } +} + +public object YourPodcasts : NavigationScreens("yourPodcasts") { + public fun destination(): String = navRoute +} + +public object LatestEpisodes : NavigationScreens("latestEpisodes") { + public fun destination(): String = navRoute +} + +public object PodcastDetails : NavigationScreens("podcast?podcastUri={podcastUri}") { + public const val PODCAST_URI: String = "podcastUri" + public fun destination(podcastUri: String): String { + val encodedUri = Uri.encode(podcastUri) + return "podcast?$PODCAST_URI=$encodedUri" + } -@Serializable -data object LatestEpisodes : NavigationScreen + override val arguments: List + get() = listOf( + navArgument(PODCAST_URI) { + type = NavType.StringType + }, + ) +} -@Serializable -data class PodcastDetails(val podcastUri: String) : NavigationScreen +public object Episode : NavigationScreens("episode?episodeUri={episodeUri}") { + public const val EPISODE_URI: String = "episodeUri" + public fun destination(episodeUri: String): String { + val encodedUri = Uri.encode(episodeUri) + return "episode?$EPISODE_URI=$encodedUri" + } -@Serializable -data class Episode(val episodeUri: String) : NavigationScreen + override val arguments: List + get() = listOf( + navArgument(EPISODE_URI) { + type = NavType.StringType + }, + ) +} -@Serializable -data object UpNext : NavigationScreen +public object UpNext : NavigationScreens("upNext") { + public fun destination(): String = navRoute +} \ No newline at end of file diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt index 92f78be6a5..02eda9a1c8 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.ExperimentalWearMaterialApi import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.ButtonDefaults import androidx.wear.compose.material3.FilledTonalButton @@ -46,20 +47,21 @@ import com.google.android.horologist.annotations.ExperimentalHorologistApi /** * A placeholder chip to be displayed while the contents of the [Button] is being loaded. */ +@OptIn(ExperimentalWearMaterialApi::class) @ExperimentalHorologistApi @Composable fun PlaceholderButton( modifier: Modifier = Modifier, onClick: () -> Unit = {}, - placeholderState: PlaceholderState = rememberPlaceholderState { false }, + placeholderState: PlaceholderState = rememberPlaceholderState(false), secondaryLabel: Boolean = true, icon: Boolean = true ) { var labelText by remember { mutableStateOf("") } var imageVector: ImageVector? by remember { mutableStateOf(null) } - val buttonPlaceholderState = rememberPlaceholderState { + val buttonPlaceholderState = rememberPlaceholderState ( labelText.isNotEmpty() && imageVector != null - } + ) FilledTonalButton( onClick = { onClick }, enabled = true, @@ -100,11 +102,6 @@ fun PlaceholderButton( } else { null }, - colors = - PlaceholderDefaults.placeholderButtonColors( - originalButtonColors = ButtonDefaults.buttonColors(), - placeholderState = buttonPlaceholderState - ), modifier = modifier.fillMaxWidth() .placeholderShimmer(buttonPlaceholderState) ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index ef40487280..3efc4189c3 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -17,7 +17,9 @@ package com.example.jetcaster.ui.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -27,10 +29,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import com.example.jetcaster.R import com.example.jetcaster.ui.player.PlayerUiState +import com.google.android.horologist.audio.AudioOutput import com.google.android.horologist.audio.ui.VolumeUiState -import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults -import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton -import com.google.android.horologist.audio.ui.components.actions.SettingsButton +import com.google.android.horologist.audio.ui.material3.components.actions.SettingsButton +import com.google.android.horologist.audio.ui.material3.components.actions.VolumeButtonWithBadge +import com.google.android.horologist.audio.ui.material3.components.toAudioOutputUi + /** * Settings buttons for the Jetcaster media app. @@ -50,23 +54,24 @@ fun SettingsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - PlaybackSpeedButton( - currentPlayerSpeed = playerUiState.episodePlayerState - .playbackSpeed.toMillis().toFloat() / 1000, - onPlaybackSpeedChange = onPlaybackSpeedChange, - enabled = enabled, - ) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + VolumeButtonWithBadge( + onOutputClick = onVolumeClick, + audioOutputUi = AudioOutput.BluetoothHeadset(id = "id", name = "name") + .toAudioOutputUi(), + volumeUiState = volumeUiState + ) + } - SettingsButtonsDefaults.BrandIcon( - iconId = R.drawable.ic_logo, - enabled = enabled, - ) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + PlaybackSpeedButton( + currentPlayerSpeed = playerUiState.episodePlayerState + .playbackSpeed.toMillis().toFloat() / 1000, + onPlaybackSpeedChange = onPlaybackSpeedChange, + enabled = enabled + ) + } - SetVolumeButton( - onVolumeClick = onVolumeClick, - volumeUiState = volumeUiState, - enabled = enabled, - ) } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt index 569abb9b75..9d52d32e50 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt @@ -16,15 +16,19 @@ package com.example.jetcaster.ui.episode +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -41,6 +45,7 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.ButtonGroup import androidx.wear.compose.material3.FilledIconButton import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader @@ -123,6 +128,7 @@ fun EpisodeScreen( title = { stringResource(R.string.episode_info_not_available) } ) } + EpisodeScreenState.Loading -> { EpisodeScreenLoading(columnState, contentPadding, modifier) } @@ -177,44 +183,52 @@ fun LoadedButtonsContent( enabled: Boolean = true, modifier: Modifier = Modifier ) { + val playInteractionSource = remember { MutableInteractionSource() } + val addToQueueInteractionSource = remember { MutableInteractionSource() } - Row( + Box( modifier = modifier .padding(bottom = 16.dp) .height(52.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + contentAlignment = Alignment.Center ) { + ButtonGroup(Modifier.fillMaxWidth()) { - FilledIconButton( - onClick = { - onPlayButtonClick() - onPlayEpisode(episode) - }, - modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = enabled, - shapes = PlayIconShape() - ) { - Icon( - painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) - ) - } + FilledIconButton( + onClick = { + onPlayButtonClick() + onPlayEpisode(episode) + }, + modifier = Modifier + .weight(weight = 0.7F) + .animateWidth(playInteractionSource), + enabled = enabled, + interactionSource = playInteractionSource, + shapes = PlayIconShape() + ) { + Icon( + painter = painterResource(id = R.drawable.play), + contentDescription = stringResource(id = R.string.button_play_content_description) + ) + } - FilledIconButton( - onClick = { onAddToQueue(episode) }, - modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = enabled - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(id = R.string.add_to_queue_content_description) - ) + FilledIconButton( + onClick = { onAddToQueue(episode) }, + modifier = Modifier + .weight(weight = 0.3F) + .animateWidth(addToQueueInteractionSource), + interactionSource = addToQueueInteractionSource, + enabled = enabled + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(id = R.string.add_to_queue_content_description) + ) + } } } } + @Composable fun EpisodeScreenLoading( columnState: TransformingLazyColumnState, @@ -345,6 +359,7 @@ fun EpisodeScreenEmptyPreview() { onDismiss = {} ) } + @WearPreviewDevices @WearPreviewFontScales @Composable diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt index da4357873a..f0eae2c876 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt @@ -62,7 +62,9 @@ class EpisodeViewModel @Inject constructor( ) : ViewModel() { private val episodeUri: String = - savedStateHandle.toRoute().episodeUri + savedStateHandle.get(Episode.EPISODE_URI).let { + Uri.decode(it) + } private val episodeFlow = if (episodeUri != null) { episodeStore.episodeAndPodcastWithUri(episodeUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt index 8d4433e8c6..ffd762c07a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt @@ -49,7 +49,10 @@ import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import coil.compose.AsyncImage @@ -250,10 +253,12 @@ fun LibraryScreen( scrollState = columnState, contentPadding = contentPadding, modifier = modifier - ) { + ) { contentPadding -> + val transformationSpec = rememberTransformationSpec() TransformingLazyColumn(state = columnState, contentPadding = contentPadding) { item { - ListHeader { + ListHeader (modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec)) { Text(stringResource(R.string.home_library)) } } @@ -270,7 +275,8 @@ fun LibraryScreen( colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec) ) } item { @@ -280,11 +286,13 @@ fun LibraryScreen( icon = { IconWithBackground(R.drawable.podcast, stringResource(R.string.podcasts)) }, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec) ) } item { - ListHeader { + ListHeader(modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec)) { Text(stringResource(R.string.queue)) } } @@ -298,7 +306,8 @@ fun LibraryScreen( icon = { IconWithBackground(R.drawable.up_next, stringResource(R.string.up_next)) }, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec) ) } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index b24b8645bf..01cc0f553f 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -32,30 +32,50 @@ package com.example.jetcaster.ui.player * limitations under the License. */ +import android.content.Context +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.session.MediaSession +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.rememberActiveFocusRequester +import androidx.wear.compose.foundation.requestFocusOnHierarchyActive import androidx.wear.compose.foundation.rotary.rotaryScrollable import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material3.MaterialTheme import com.example.jetcaster.R import com.example.jetcaster.ui.components.SettingsButtons import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.audio.ui.volumeRotaryBehavior +import com.google.android.horologist.images.base.paintable.DrawableResPaintable import com.google.android.horologist.images.coil.CoilPaintable -import com.google.android.horologist.media.ui.components.PodcastControlButtons -import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement -import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay -import com.google.android.horologist.media.ui.components.display.TextMediaDisplay -import com.google.android.horologist.media.ui.screens.player.PlayerScreen +import com.google.android.horologist.media.ui.material3.components.PodcastControlButtons +import com.google.android.horologist.media.ui.material3.components.background.ArtworkImageBackground +import com.google.android.horologist.media.ui.material3.components.animated.MarqueeTextMediaDisplay +import com.google.android.horologist.media.ui.material3.components.display.LoadingMediaDisplay +import com.google.android.horologist.media.ui.material3.components.display.TextMediaDisplay +import com.google.android.horologist.media.ui.material3.screens.player.PlayerScreen +import java.time.Duration @Composable fun PlayerScreen( @@ -71,10 +91,11 @@ fun PlayerScreen( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, - modifier = modifier, + modifier = modifier ) } +@androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class) @Composable private fun PlayerScreen( @@ -85,6 +106,8 @@ private fun PlayerScreen( modifier: Modifier = Modifier, ) { val uiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val focusRequester = remember { FocusRequester() } when (val state = uiState) { PlayerScreenUiState.Loading -> LoadingMediaDisplay(modifier) @@ -94,6 +117,7 @@ private fun PlayerScreen( TextMediaDisplay( title = stringResource(R.string.nothing_playing), subtitle = "", + titleIcon = DrawableResPaintable(R.drawable.ic_logo) ) }, controlButtons = { @@ -105,7 +129,7 @@ private fun PlayerScreen( onSeekBackButtonClick = playerScreenViewModel::onRewindBy, seekBackButtonEnabled = false, onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, - seekForwardButtonEnabled = false, + seekForwardButtonEnabled = false ) }, buttons = { @@ -125,61 +149,131 @@ private fun PlayerScreen( // return a null episode val episode = state.playerState.episodePlayerState.currentEpisode - PlayerScreen( - mediaDisplay = { - if (episode != null && episode.title.isNotEmpty()) { - TextMediaDisplay( - title = episode.podcastName, - subtitle = episode.title, - ) - } else { - TextMediaDisplay( - title = stringResource(R.string.nothing_playing), - subtitle = "", - ) - } - }, + val exoPlayer = rememberPlayer(context) - controlButtons = { - PodcastControlButtons( - onPlayButtonClick = playerScreenViewModel::onPlay, - onPauseButtonClick = playerScreenViewModel::onPause, - playPauseButtonEnabled = true, - playing = state.playerState.episodePlayerState.isPlaying, - onSeekBackButtonClick = playerScreenViewModel::onRewindBy, - seekBackButtonEnabled = true, - onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, - seekForwardButtonEnabled = true, - seekBackButtonIncrement = SeekButtonIncrement.Ten, - seekForwardButtonIncrement = SeekButtonIncrement.Ten, - trackPositionUiModel = state.playerState.trackPositionUiModel, - ) - }, - buttons = { - SettingsButtons( - volumeUiState = volumeUiState, - onVolumeClick = onVolumeClick, - playerUiState = state.playerState, - onPlaybackSpeedChange = playerScreenViewModel::onPlaybackSpeedChange, - enabled = true, + DisposableEffect(exoPlayer, episode) { + episode?.mediaUrls?.let { exoPlayer.setMediaItems(it.map { MediaItem.fromUri(it) }) } + val mediaSession = MediaSession.Builder(context, exoPlayer).build() + + exoPlayer.prepare() + + onDispose { + mediaSession.release() + exoPlayer.release() + } + } + Box { + PlayerSurface( + player = exoPlayer, + modifier = Modifier.resizeWithContentScale( + contentScale = ContentScale.Fit, + sourceSizeDp = null ) - }, - modifier = modifier - .rotaryScrollable( - volumeRotaryBehavior( - volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = { onUpdateVolume }, + ) + PlayerScreen( + mediaDisplay = { + if (episode != null && episode.title.isNotEmpty()) { + MarqueeTextMediaDisplay( + title = episode.title, + artist = episode.podcastName, + titleIcon = DrawableResPaintable(R.drawable.ic_logo) + ) + } else { + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "", + titleIcon = DrawableResPaintable(R.drawable.ic_logo) + ) + } + }, + + controlButtons = { + PodcastControlButtons( + onPlayButtonClick = ({ + playerScreenViewModel.onPlay() + exoPlayer.play() + }), + onPauseButtonClick = ({ + playerScreenViewModel.onPause() + exoPlayer.pause() + }), + playPauseButtonEnabled = true, + playing = state.playerState.episodePlayerState.isPlaying, + onSeekBackButtonClick = ({ + playerScreenViewModel.onRewindBy() + exoPlayer.seekBack() + }), + seekBackButtonEnabled = true, + onSeekForwardButtonClick = ({ + playerScreenViewModel.onAdvanceBy() + exoPlayer.seekForward() + }), + seekForwardButtonEnabled = true, + seekBackButtonIncrement = SeekButtonIncrement.Ten, + seekForwardButtonIncrement = SeekButtonIncrement.Ten, + trackPositionUiModel = state.playerState.trackPositionUiModel + ) + }, + buttons = { + SettingsButtons( + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + playerUiState = state.playerState, + onPlaybackSpeedChange = ({ + playerScreenViewModel.onPlaybackSpeedChange() + if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( + 1 + ) + ) + exoPlayer.setPlaybackSpeed(1.5F) + else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofMillis( + 1500 + ) + ) + exoPlayer.setPlaybackSpeed(2.0F) + else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( + 2 + ) + ) + exoPlayer.setPlaybackSpeed(1.0F) + }), + enabled = true, + ) + }, + modifier = modifier + .requestFocusOnHierarchyActive() + .rotaryScrollable( + volumeRotaryBehavior( + volumeUiStateProvider = { volumeUiState }, + onRotaryVolumeInput = { onUpdateVolume } + ), + focusRequester = focusRequester ), - focusRequester = rememberActiveFocusRequester(), - ), - background = { - ArtworkColorBackground( - paintable = episode?.let { CoilPaintable(episode.podcastImageUrl) }, - defaultColor = MaterialTheme.colors.primary, - modifier = Modifier.fillMaxSize(), - ) - }, - ) + background = { + ArtworkImageBackground( + artwork = episode?.let { CoilPaintable(episode.podcastImageUrl) }, + colorScheme = MaterialTheme.colorScheme, + modifier = Modifier.fillMaxSize(), + ) + } + ) + } } } } + + + @androidx.annotation.OptIn(UnstableApi::class) + @Composable + internal fun rememberPlayer( + context: Context, + ) = remember { + ExoPlayer.Builder(context).setSeekForwardIncrementMs(10000).setSeekBackIncrementMs(10000) + .setMediaSourceFactory( + ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + ).setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING).build().apply { + playWhenReady = true + repeatMode = Player.REPEAT_MODE_ALL + } + } + diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index 81cccd8c06..cbc2b96ce8 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -66,7 +66,9 @@ class PodcastDetailsViewModel @Inject constructor( ) : ViewModel() { private val podcastUri: String = - Uri.decode(savedStateHandle.toRoute().podcastUri) + savedStateHandle.get(PodcastDetails.PODCAST_URI).let { + Uri.decode(it) + } private val podcastFlow = if (podcastUri != null) { podcastStore.podcastWithExtraInfo(podcastUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt index 8bc08ed80d..3c1d866c18 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.ui.queue +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -204,6 +206,8 @@ fun ButtonsContent( enabled: Boolean = true, modifier: Modifier = Modifier, ) { + val interactionSource1 = remember { MutableInteractionSource() } + val interactionSource2 = remember { MutableInteractionSource() } Box(modifier = modifier .padding(bottom = 16.dp) @@ -216,8 +220,10 @@ fun ButtonsContent( onPlayEpisodes(episodes) }, modifier = modifier - .weight(weight = 0.7F), + .weight(weight = 0.7F) + .animateWidth(interactionSource1), enabled = enabled, + interactionSource = interactionSource1, shapes = PlayIconShape() ) { Icon( @@ -228,7 +234,9 @@ fun ButtonsContent( FilledIconButton( onClick = onDeleteQueueEpisodes, modifier = modifier - .weight(weight = 0.3F), + .weight(weight = 0.3F) + .animateWidth(interactionSource2), + interactionSource = interactionSource2, enabled = enabled ) { Icon( From 81f4d2385eab1d7efa1f4b930965739750d56429 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Mon, 19 May 2025 16:54:25 +0100 Subject: [PATCH 4/4] Spotless apply --- Jetcaster/gradle/libs.versions.toml | 6 +- Jetcaster/wear/build.gradle | 5 +- .../com/example/jetcaster/MainActivity.kt | 1 - .../java/com/example/jetcaster/WearApp.kt | 6 +- .../example/jetcaster/theme/ColorScheme.kt | 2 +- .../PlayIconShape.kt => theme/Shape.kt} | 14 +- .../example/jetcaster/theme/WearAppTheme.kt | 5 +- .../jetcaster/ui/JetcasterNavController.kt | 5 +- .../jetcaster/ui/components/MediaContent.kt | 24 +-- .../ui/components/PlaceholderButton.kt | 108 ---------- .../ui/components/SettingsButtons.kt | 6 +- .../jetcaster/ui/episode/EpisodeScreen.kt | 188 +++++++--------- .../jetcaster/ui/episode/EpisodeViewModel.kt | 1 - .../latest_episodes/LatestEpisodesScreen.kt | 97 +++------ .../jetcaster/ui/library/LibraryScreen.kt | 202 +++++++++--------- .../jetcaster/ui/player/PlayerScreen.kt | 127 +++++------ .../ui/podcast/PodcastDetailsScreen.kt | 107 +++------- .../ui/podcast/PodcastDetailsViewModel.kt | 13 +- .../jetcaster/ui/podcasts/PodcastsScreen.kt | 150 +++++-------- .../example/jetcaster/ui/queue/QueueScreen.kt | 126 +++++------ 20 files changed, 468 insertions(+), 725 deletions(-) rename Jetcaster/wear/src/main/java/com/example/jetcaster/{ui/components/PlayIconShape.kt => theme/Shape.kt} (68%) delete mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index aa3c9bee90..eaa4e1d810 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -38,7 +38,6 @@ gradle-versions = "0.52.0" hilt = "2.56.2" hiltExt = "1.2.0" horologist = "0.7.14-beta" -# @pin When updating to AGP 7.4.0-alpha10 and up we can update this https://developer.android.com/studio/write/java8-support#library-desugaring-versions jdkDesugar = "2.1.5" junit = "4.13.2" kotlin = "2.1.20" @@ -60,8 +59,6 @@ spotless = "7.0.3" # @keep targetSdk = "33" version-catalog-update = "1.0.0" -material3Android = "1.3.1" -media3CommonKtx = "1.7.1" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -170,8 +167,7 @@ rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } androidx-media3-session = {module = "androidx.media3:media3-session",version.ref = "media3"} androidx-media3-exoplayer = {module = "androidx.media3:media3-exoplayer", version.ref = "media3"} -androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } -androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3CommonKtx" } +androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index e56e84f0ac..59e7886262 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -20,7 +20,6 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.compose) - alias(libs.plugins.kotlin.serialization) } android { @@ -75,7 +74,6 @@ android { dependencies { - implementation libs.androidx.media3.common.ktx def composeBom = platform(libs.androidx.compose.bom) // General compose dependencies @@ -92,7 +90,7 @@ dependencies { implementation libs.androidx.wear.compose.material // For using the phone Typography - implementation libs.androidx.material3.android + implementation libs.androidx.compose.material3 implementation(libs.kotlinx.collections.immutable) @@ -104,6 +102,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.session) + implementation libs.androidx.media3.common.ktx // Horologist for correct Compose layout implementation libs.horologist.compose.layout diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index faca6663b1..544e3d212c 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -19,7 +19,6 @@ package com.example.jetcaster import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.NavHostController import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import dagger.hilt.android.AndroidEntryPoint diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index 114f85842f..df24bc229f 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -25,11 +25,11 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController -import androidx.wear.compose.navigation.SwipeDismissableNavHost -import androidx.wear.compose.navigation.composable import androidx.wear.compose.foundation.pager.rememberPagerState import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.example.jetcaster.theme.WearAppTheme import com.example.jetcaster.ui.Episode @@ -49,8 +49,8 @@ import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.podcast.PodcastDetailsScreen import com.example.jetcaster.ui.podcasts.PodcastsScreen import com.example.jetcaster.ui.queue.QueueScreen -import com.google.android.horologist.audio.ui.material3.VolumeScreen import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.audio.ui.material3.VolumeScreen import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToVolume import com.google.android.horologist.media.ui.material3.navigation.NavigationScreens diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt index 01f9b51ab0..bf75022e51 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt @@ -44,7 +44,7 @@ import com.example.jetcaster.designsystem.theme.tertiaryDark internal val wearColorPalette: ColorScheme = ColorScheme( primary = primaryDark, - primaryDim= primaryDark, + primaryDim = primaryDark, onPrimary = Color(0xFF542104), primaryContainer = primaryContainerDark, onPrimaryContainer = onPrimaryContainerDark, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Shape.kt similarity index 68% rename from Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt rename to Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Shape.kt index 6d86d4a235..b99d29d444 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlayIconShape.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Shape.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2025 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. @@ -14,14 +14,12 @@ * limitations under the License. */ -package com.example.jetcaster.ui.components +package com.example.jetcaster.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp -import androidx.wear.compose.material3.IconButtonShapes +import androidx.wear.compose.material3.Shapes -@Composable -fun PlayIconShape(): IconButtonShapes { - return IconButtonShapes(RoundedCornerShape(16.dp)) -} +val Shapes = Shapes( + medium = RoundedCornerShape(16.dp), +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt index 2d93dd1b9a..3b35e57a23 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt @@ -24,8 +24,7 @@ fun WearAppTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = wearColorPalette, typography = Typography, - // For shapes, we generally recommend using the default Material Wear shapes which are - // optimized for round devices. - content = content + shapes = Shapes, + content = content, ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt index 8dc0540c7c..01372473d0 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -21,10 +21,7 @@ import androidx.navigation.NamedNavArgument import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.navArgument -import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer -import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToVolume import com.google.android.horologist.media.ui.material3.navigation.NavigationScreens -import com.google.android.horologist.media.ui.material3.screens.playerlibrarypager.PlayerLibraryPagerScreen /** * NavController extensions that links to the screens of the Jetcaster app. @@ -92,4 +89,4 @@ public object Episode : NavigationScreens("episode?episodeUri={episodeUri}") { public object UpNext : NavigationScreens("upNext") { public fun destination(): String = navRoute -} \ No newline at end of file +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt index e0209c74bf..d1ecc9120c 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -50,9 +49,9 @@ import java.time.format.FormatStyle @Composable fun MediaContent( episode: PlayerEpisode, - episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), onItemClick: (PlayerEpisode) -> Unit, modifier: Modifier = Modifier, + episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), ) { val mediaTitle = episode.title val duration = episode.duration @@ -76,7 +75,7 @@ fun MediaContent( Text( mediaTitle, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) }, onClick = { onItemClick(episode) }, @@ -84,7 +83,7 @@ fun MediaContent( Text( secondaryLabel, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) }, icon = { @@ -96,13 +95,13 @@ fun MediaContent( contentScale = ContentScale.Crop, modifier = Modifier .size( - ButtonDefaults.LargeIconSize + ButtonDefaults.LargeIconSize, ) - .clip(CircleShape) + .clip(CircleShape), ) }, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), ) } @@ -111,21 +110,22 @@ fun MediaContent( @Composable fun MediaContentPreview( @PreviewParameter(WearPreviewEpisodes::class) - episode: PlayerEpisode + episode: PlayerEpisode, + modifier: Modifier = Modifier, ) { - AppScaffold { + AppScaffold(modifier = modifier) { val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.Button + first = ColumnItemType.Button, ) ScreenScaffold(contentPadding = contentPadding) { Box( modifier = Modifier .fillMaxWidth() - .padding(contentPadding) + .padding(contentPadding), ) { MediaContent( - episode, onItemClick = { null } + episode, onItemClick = { null }, ) } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt deleted file mode 100644 index 02eda9a1c8..0000000000 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/PlaceholderButton.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material3.Button -import androidx.wear.compose.material3.ButtonDefaults -import androidx.wear.compose.material3.FilledTonalButton -import androidx.wear.compose.material3.PlaceholderDefaults -import androidx.wear.compose.material3.PlaceholderState -import androidx.wear.compose.material3.placeholder -import androidx.wear.compose.material3.placeholderShimmer -import androidx.wear.compose.material3.rememberPlaceholderState -import com.google.android.horologist.annotations.ExperimentalHorologistApi - -/** - * A placeholder chip to be displayed while the contents of the [Button] is being loaded. - */ -@OptIn(ExperimentalWearMaterialApi::class) -@ExperimentalHorologistApi -@Composable -fun PlaceholderButton( - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, - placeholderState: PlaceholderState = rememberPlaceholderState(false), - secondaryLabel: Boolean = true, - icon: Boolean = true -) { - var labelText by remember { mutableStateOf("") } - var imageVector: ImageVector? by remember { mutableStateOf(null) } - val buttonPlaceholderState = rememberPlaceholderState ( - labelText.isNotEmpty() && imageVector != null - ) - FilledTonalButton( - onClick = { onClick }, - enabled = true, - label = { - Column { - Box( - modifier = modifier - .padding(end = 10.dp) - .clip(RoundedCornerShape(12.dp)) - .fillMaxWidth() - .height(12.dp) - .placeholder(placeholderState), - ) - Spacer(Modifier.size(8.dp)) - } - }, - secondaryLabel = if (secondaryLabel) { - { - Box( - modifier = modifier - .fillMaxWidth() - .padding(end = 30.dp) - .clip(RoundedCornerShape(12.dp)) - .height(12.dp) - .placeholder(placeholderState), - ) - } - } else { - null - }, - icon = if (icon) { - { - Box( - modifier = - modifier.size(ButtonDefaults.IconSize).placeholder(buttonPlaceholderState) - ) - } - } else { - null - }, - modifier = modifier.fillMaxWidth() - .placeholderShimmer(buttonPlaceholderState) - ) -} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index 3efc4189c3..d913c8c48b 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -35,7 +35,6 @@ import com.google.android.horologist.audio.ui.material3.components.actions.Setti import com.google.android.horologist.audio.ui.material3.components.actions.VolumeButtonWithBadge import com.google.android.horologist.audio.ui.material3.components.toAudioOutputUi - /** * Settings buttons for the Jetcaster media app. * Add to queue and Set Volume. @@ -59,7 +58,7 @@ fun SettingsButtons( onOutputClick = onVolumeClick, audioOutputUi = AudioOutput.BluetoothHeadset(id = "id", name = "name") .toAudioOutputUi(), - volumeUiState = volumeUiState + volumeUiState = volumeUiState, ) } @@ -68,10 +67,9 @@ fun SettingsButtons( currentPlayerSpeed = playerUiState.episodePlayerState .playbackSpeed.toMillis().toFloat() / 1000, onPlaybackSpeedChange = onPlaybackSpeedChange, - enabled = enabled + enabled = enabled, ) } - } } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt index 9d52d32e50..85f48ac47e 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt @@ -17,19 +17,14 @@ package com.example.jetcaster.ui.episode import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -44,15 +39,21 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material.ExperimentalWearMaterialApi import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.ButtonGroup import androidx.wear.compose.material3.FilledIconButton import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButtonShapes import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.LocalContentColor import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R @@ -60,13 +61,12 @@ import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.core.player.model.toPlayerEpisode import com.example.jetcaster.designsystem.component.HtmlTextContainer import com.example.jetcaster.ui.components.MediumDateFormatter -import com.example.jetcaster.ui.components.PlaceholderButton -import com.example.jetcaster.ui.components.PlayIconShape import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding +@OptIn(ExperimentalWearMaterialApi::class) @Composable fun EpisodeScreen( onPlayButtonClick: () -> Unit, @@ -75,10 +75,12 @@ fun EpisodeScreen( episodeViewModel: EpisodeViewModel = hiltViewModel(), ) { val uiState by episodeViewModel.uiState.collectAsStateWithLifecycle() + val placeholderState = rememberPlaceholderState(isVisible = uiState is EpisodeScreenState.Loading) EpisodeScreen( uiState = uiState, onPlayButtonClick = onPlayButtonClick, + placeholderState = placeholderState, onPlayEpisode = episodeViewModel::onPlayEpisode, onAddToQueue = episodeViewModel::addToQueue, onDismiss = onDismiss, @@ -89,6 +91,7 @@ fun EpisodeScreen( @Composable fun EpisodeScreen( uiState: EpisodeScreenState, + placeholderState: PlaceholderState, onPlayButtonClick: () -> Unit, onPlayEpisode: (PlayerEpisode) -> Unit, onAddToQueue: (PlayerEpisode) -> Unit, @@ -97,14 +100,14 @@ fun EpisodeScreen( ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { contentPadding -> when (uiState) { is EpisodeScreenState.Loaded -> { @@ -117,20 +120,30 @@ fun EpisodeScreen( onPlayEpisode = onPlayEpisode, onAddToQueue = onAddToQueue, columnState = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, + placeholderState = placeholderState, ) } EpisodeScreenState.Empty -> { AlertDialog( visible = true, - onDismissRequest = { onDismiss }, - title = { stringResource(R.string.episode_info_not_available) } + onDismissRequest = { onDismiss() }, + title = { stringResource(R.string.episode_info_not_available) }, ) } EpisodeScreenState.Loading -> { - EpisodeScreenLoading(columnState, contentPadding, modifier) + EpisodeScreenLoaded( + title = stringResource(R.string.loading), + episode = PlayerEpisode(), + onPlayButtonClick = { }, + onPlayEpisode = { }, + onAddToQueue = { }, + columnState = columnState, + contentPadding = contentPadding, + placeholderState = placeholderState, + ) } } } @@ -145,12 +158,13 @@ fun EpisodeScreenLoaded( onAddToQueue: (PlayerEpisode) -> Unit, columnState: TransformingLazyColumnState, contentPadding: PaddingValues, - modifier: Modifier = Modifier + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( modifier = modifier, state = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, ) { item { ListHeader { @@ -158,7 +172,8 @@ fun EpisodeScreenLoaded( text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + modifier = Modifier.placeholder(placeholderState), ) } } @@ -167,10 +182,15 @@ fun EpisodeScreenLoaded( episode = episode, onPlayButtonClick = onPlayButtonClick, onPlayEpisode = onPlayEpisode, - onAddToQueue = onAddToQueue + onAddToQueue = onAddToQueue, + placeholderState = placeholderState, + ) + } + if (!placeholderState.isVisible) { + episodeInfoContent( + episode = episode, ) } - episodeInfoContent(episode = episode) } } @@ -180,113 +200,46 @@ fun LoadedButtonsContent( onPlayButtonClick: () -> Unit, onPlayEpisode: (PlayerEpisode) -> Unit, onAddToQueue: (PlayerEpisode) -> Unit, + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, enabled: Boolean = true, - modifier: Modifier = Modifier ) { val playInteractionSource = remember { MutableInteractionSource() } val addToQueueInteractionSource = remember { MutableInteractionSource() } - Box( - modifier = modifier - .padding(bottom = 16.dp) - .height(52.dp), - contentAlignment = Alignment.Center - ) { - ButtonGroup(Modifier.fillMaxWidth()) { - - FilledIconButton( - onClick = { - onPlayButtonClick() - onPlayEpisode(episode) - }, - modifier = Modifier - .weight(weight = 0.7F) - .animateWidth(playInteractionSource), - enabled = enabled, - interactionSource = playInteractionSource, - shapes = PlayIconShape() - ) { - Icon( - painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) - ) - } - - FilledIconButton( - onClick = { onAddToQueue(episode) }, - modifier = Modifier - .weight(weight = 0.3F) - .animateWidth(addToQueueInteractionSource), - interactionSource = addToQueueInteractionSource, - enabled = enabled - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(id = R.string.add_to_queue_content_description) - ) - } - } - } -} - -@Composable -fun EpisodeScreenLoading( - columnState: TransformingLazyColumnState, - contentPadding: PaddingValues, - modifier: Modifier = Modifier -) { - TransformingLazyColumn( - modifier = modifier, - state = columnState, - contentPadding = contentPadding - ) { - item { - ListHeader { - Text(text = stringResource(R.string.loading)) - } - } - item { - LoadingButtonsContent() - } - items(count = 2) { - PlaceholderButton() - } - } -} - -@Composable -fun LoadingButtonsContent() { - Row( - modifier = Modifier - .padding(bottom = 16.dp) - .height(52.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { + ButtonGroup(modifier.fillMaxWidth().padding(bottom = 16.dp)) { FilledIconButton( onClick = { + onPlayButtonClick() + onPlayEpisode(episode) }, modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = false, - shapes = PlayIconShape() + .weight(weight = 0.7F) + .animateWidth(playInteractionSource) + .placeholder(placeholderState = placeholderState), + enabled = enabled, + interactionSource = playInteractionSource, + shapes = IconButtonShapes(MaterialTheme.shapes.medium), ) { Icon( painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) + contentDescription = stringResource(id = R.string.button_play_content_description), ) } FilledIconButton( - onClick = { }, + onClick = { onAddToQueue(episode) }, modifier = Modifier - .weight(weight = 0.3F, fill = false), - enabled = false + .weight(weight = 0.3F) + .animateWidth(addToQueueInteractionSource) + .placeholder(placeholderState), + interactionSource = addToQueueInteractionSource, + enabled = enabled, ) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = stringResource(id = R.string.add_to_queue_content_description) + contentDescription = stringResource(id = R.string.add_to_queue_content_description), ) } } @@ -304,7 +257,7 @@ private fun TransformingLazyColumnScope.episodeInfoContent(episode: PlayerEpisod text = author, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -356,7 +309,8 @@ fun EpisodeScreenEmptyPreview() { onPlayButtonClick = { }, onPlayEpisode = { _ -> }, onAddToQueue = { _ -> }, - onDismiss = {} + onDismiss = {}, + placeholderState = rememberPlaceholderState(isVisible = true), ) } @@ -365,15 +319,24 @@ fun EpisodeScreenEmptyPreview() { @Composable fun EpisodeScreenLoadingPreview( @PreviewParameter(WearPreviewEpisodes::class) - episode: PlayerEpisode + episode: PlayerEpisode, ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() - EpisodeScreenLoading(columnState, contentPadding) + EpisodeScreenLoaded( + title = episode.title, + episode = episode, + onPlayButtonClick = { }, + onPlayEpisode = { }, + onAddToQueue = { }, + columnState = columnState, + contentPadding = contentPadding, + placeholderState = rememberPlaceholderState(isVisible = true), + ) } @WearPreviewDevices @@ -381,12 +344,12 @@ fun EpisodeScreenLoadingPreview( @Composable fun EpisodeScreenLoadedPreview( @PreviewParameter(WearPreviewEpisodes::class) - episode: PlayerEpisode + episode: PlayerEpisode, ) { val columnState = rememberTransformingLazyColumnState() val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) EpisodeScreenLoaded( @@ -396,6 +359,7 @@ fun EpisodeScreenLoadedPreview( onPlayEpisode = { }, onAddToQueue = { }, columnState = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, + placeholderState = rememberPlaceholderState(isVisible = false), ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt index f0eae2c876..7a15b622b3 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt @@ -36,7 +36,6 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.player.EpisodePlayer diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt index e730859959..cd7271a607 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -36,18 +35,20 @@ import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent -import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding -import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun LatestEpisodesScreen( onPlayButtonClick: () -> Unit, @@ -56,11 +57,14 @@ import com.google.android.horologist.images.base.util.rememberVectorPainter latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel(), ) { val uiState by latestEpisodeViewModel.uiState.collectAsStateWithLifecycle() + val placeholderState = rememberPlaceholderState(isVisible = uiState is LatestEpisodeScreenState.Loading) + LatestEpisodeScreen( modifier = modifier, uiState = uiState, onPlayButtonClick = onPlayButtonClick, onDismiss = onDismiss, + placeholderState = placeholderState, onPlayEpisodes = latestEpisodeViewModel::onPlayEpisodes, onPlayEpisode = latestEpisodeViewModel::onPlayEpisode, ) @@ -69,6 +73,7 @@ import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun LatestEpisodeScreen( uiState: LatestEpisodeScreenState, + placeholderState: PlaceholderState, onPlayButtonClick: () -> Unit, onDismiss: () -> Unit, onPlayEpisodes: (List) -> Unit, @@ -77,14 +82,14 @@ fun LatestEpisodeScreen( ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { contentPadding -> when (uiState) { is LatestEpisodeScreenState.Loaded -> { @@ -95,7 +100,7 @@ fun LatestEpisodeScreen( onPlayEpisodes = onPlayEpisodes, contentPadding = contentPadding, scrollState = columnState, - modifier = modifier + placeholderState = placeholderState, ) } @@ -108,10 +113,14 @@ fun LatestEpisodeScreen( } is LatestEpisodeScreenState.Loading -> { - LatestEpisodesScreenLoading( + LatestEpisodesScreen( + episodeList = emptyList(), + onPlayButtonClick = { }, + onPlayEpisode = { }, + onPlayEpisodes = {}, contentPadding = contentPadding, scrollState = columnState, - modifier = modifier + placeholderState = placeholderState, ) } } @@ -123,8 +132,9 @@ fun ButtonsContent( episodes: List, onPlayButtonClick: () -> Unit, onPlayEpisodes: (List) -> Unit, + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, enabled: Boolean = true, - modifier: Modifier = Modifier ) { Button( onClick = { @@ -135,10 +145,10 @@ fun ButtonsContent( icon = { Icon( painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) + contentDescription = stringResource(id = R.string.button_play_content_description), ) }, - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().placeholder(placeholderState = placeholderState), ) { Text(stringResource(id = R.string.button_play_content_description)) } @@ -150,23 +160,25 @@ fun LatestEpisodesScreen( onPlayButtonClick: () -> Unit, onPlayEpisode: (PlayerEpisode) -> Unit, onPlayEpisodes: (List) -> Unit, - modifier: Modifier = Modifier, contentPadding: PaddingValues, - scrollState: TransformingLazyColumnState + scrollState: TransformingLazyColumnState, + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( modifier = modifier, state = scrollState, - contentPadding = contentPadding + contentPadding = contentPadding, ) { item { - LatestEpisodesListHeader() + LatestEpisodesListHeader(placeholderState) } item { ButtonsContent( episodes = episodeList, onPlayButtonClick = onPlayButtonClick, onPlayEpisodes = onPlayEpisodes, + placeholderState = placeholderState, ) } items(episodeList) { episode -> @@ -176,54 +188,23 @@ fun LatestEpisodesScreen( onItemClick = { onPlayButtonClick() onPlayEpisode(episode) - } + }, ) } } } @Composable -fun LatestEpisodesListHeader( - modifier: Modifier = Modifier -) { - ListHeader { +fun LatestEpisodesListHeader(placeholderState: PlaceholderState, modifier: Modifier = Modifier) { + ListHeader(modifier = modifier.placeholder(placeholderState)) { Text( text = stringResource(id = R.string.latest_episodes), maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = modifier ) } } -@Composable -fun LatestEpisodesScreenLoading( - contentPadding: PaddingValues, - scrollState: TransformingLazyColumnState, - modifier: Modifier = Modifier -) { - TransformingLazyColumn( - modifier = modifier, - state = scrollState, - contentPadding = contentPadding - ) { - item { - LatestEpisodesListHeader() - } - item { - ButtonsContent( - episodes = emptyList(), - onPlayButtonClick = { }, - onPlayEpisodes = { }, - enabled = false - ) - } - items(count = 2) { - PlaceholderButton() - } - } -} - @WearPreviewDevices @WearPreviewFontScales @Composable @@ -233,7 +214,7 @@ fun LatestEpisodeScreenLoadedPreview( ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() @@ -243,19 +224,7 @@ fun LatestEpisodeScreenLoadedPreview( onPlayEpisode = { }, onPlayEpisodes = { }, contentPadding = contentPadding, - scrollState = columnState - ) -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun LatestEpisodeScreenLoadingPreview() { - val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + scrollState = columnState, + placeholderState = rememberPlaceholderState(isVisible = false), ) - - val columnState = rememberTransformingLazyColumnState() - LatestEpisodesScreenLoading(contentPadding, columnState) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt index ffd762c07a..033cc53d8a 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt @@ -48,18 +48,21 @@ import androidx.wear.compose.material3.FilledTonalButton import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.SurfaceTransformation import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.lazy.rememberTransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.model.PlayerEpisode -import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.example.jetcaster.ui.preview.WearPreviewPodcasts import com.google.android.horologist.compose.layout.ColumnItemType @@ -74,86 +77,69 @@ fun LibraryScreen( libraryScreenViewModel: LibraryViewModel = hiltViewModel(), ) { val uiState by libraryScreenViewModel.uiState.collectAsState() + val placeholderState = rememberPlaceholderState(isVisible = uiState is LibraryScreenUiState.Loading) val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { contentPadding -> when (val s = uiState) { is LibraryScreenUiState.Loading -> - LoadingScreen( - scrollState = columnState, + LibraryScreen( + columnState = columnState, contentPadding = contentPadding, - modifier = modifier + onLatestEpisodeClick = { }, + onYourPodcastClick = { }, + onUpNextClick = { }, + placeholderState = placeholderState, + queue = emptyList(), ) is LibraryScreenUiState.NoSubscribedPodcast -> NoSubscribedPodcastScreen( columnState = columnState, contentPadding = contentPadding, - modifier = modifier, topPodcasts = s.topPodcasts, - onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed + onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed, ) is LibraryScreenUiState.Ready -> LibraryScreen( columnState = columnState, contentPadding = contentPadding, - modifier = modifier, onLatestEpisodeClick = onLatestEpisodeClick, onYourPodcastClick = onYourPodcastClick, onUpNextClick = onUpNextClick, - queue = s.queue + placeholderState = placeholderState, + queue = s.queue, ) } } } -@Composable -fun LoadingScreen( - modifier: Modifier, - scrollState: TransformingLazyColumnState, - contentPadding: PaddingValues, -) { - TransformingLazyColumn( - state = scrollState, contentPadding = contentPadding, - modifier = modifier - ) { - item { - ListHeader { - Text(text = stringResource(R.string.loading)) - } - } - items(count = 2) { - PlaceholderButton() - } - } -} - @Composable fun NoSubscribedPodcastScreen( columnState: TransformingLazyColumnState, - modifier: Modifier, topPodcasts: List, onTogglePodcastFollowed: (uri: String) -> Unit, - - contentPadding: PaddingValues + contentPadding: PaddingValues, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( - state = columnState, contentPadding = contentPadding, - modifier = modifier + state = columnState, + contentPadding = contentPadding, + modifier = modifier, ) { item { ListHeader( - contentColor = MaterialTheme.colorScheme.onSurface + contentColor = MaterialTheme.colorScheme.onSurface, ) { Text(stringResource(R.string.entity_no_featured_podcasts)) } @@ -170,19 +156,18 @@ fun NoSubscribedPodcastScreen( } } else { item { - PlaceholderButton() + PodcastContent( + podcast = PodcastInfo(), + podcastArtworkPlaceholder = painterResource(id = R.drawable.music), + onClick = {}, + ) } } } } @Composable -private fun PodcastContent( - podcast: PodcastInfo, - onClick: () -> Unit, - podcastArtworkPlaceholder: Painter?, - modifier: Modifier = Modifier, -) { +private fun PodcastContent(podcast: PodcastInfo, onClick: () -> Unit, podcastArtworkPlaceholder: Painter?, modifier: Modifier = Modifier) { val mediaTitle = podcast.title FilledTonalButton( @@ -203,62 +188,42 @@ private fun PodcastContent( placeholder = podcastArtworkPlaceholder, modifier = Modifier .size( - ButtonDefaults.LargeIconSize + ButtonDefaults.LargeIconSize, ) - .clip(CircleShape) + .clip(CircleShape), ) }, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), ) } -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun PodcastContentPreview(@PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo) { - AppScaffold { - val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.Button - ) - - ScreenScaffold(contentPadding = contentPadding) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - ) { - PodcastContent( - podcast = podcasts, - podcastArtworkPlaceholder = painterResource(id = R.drawable.music), - onClick = {}, - - ) - } - } - } -} - @Composable fun LibraryScreen( columnState: TransformingLazyColumnState, + placeholderState: PlaceholderState, contentPadding: PaddingValues, - modifier: Modifier, onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, queue: List, + modifier: Modifier = Modifier, ) { ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { contentPadding -> val transformationSpec = rememberTransformationSpec() TransformingLazyColumn(state = columnState, contentPadding = contentPadding) { item { - ListHeader (modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), - transformation = SurfaceTransformation(transformationSpec)) { + ListHeader( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholder(placeholderState), + transformation = SurfaceTransformation(transformationSpec), + ) { Text(stringResource(R.string.home_library)) } } @@ -269,14 +234,17 @@ fun LibraryScreen( icon = { IconWithBackground( R.drawable.new_releases, - stringResource(R.string.latest_episodes) + stringResource(R.string.latest_episodes), ) }, colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer + containerColor = MaterialTheme.colorScheme.surfaceContainer, ), - modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), - transformation = SurfaceTransformation(transformationSpec) + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholder(placeholderState = placeholderState), + transformation = SurfaceTransformation(transformationSpec), ) } item { @@ -286,19 +254,27 @@ fun LibraryScreen( icon = { IconWithBackground(R.drawable.podcast, stringResource(R.string.podcasts)) }, - modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), - transformation = SurfaceTransformation(transformationSpec) + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholder(placeholderState = placeholderState), + transformation = SurfaceTransformation(transformationSpec), ) } item { - ListHeader(modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), - transformation = SurfaceTransformation(transformationSpec)) { + ListHeader( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholder(placeholderState = placeholderState), + transformation = SurfaceTransformation(transformationSpec), + ) { Text(stringResource(R.string.queue)) } } item { if (queue.isEmpty()) { - QueueEmpty() + QueueEmptyText() } else { FilledTonalButton( label = { Text(stringResource(R.string.up_next)) }, @@ -306,8 +282,11 @@ fun LibraryScreen( icon = { IconWithBackground(R.drawable.up_next, stringResource(R.string.up_next)) }, - modifier = modifier.fillMaxWidth().transformedHeight(this, transformationSpec), - transformation = SurfaceTransformation(transformationSpec) + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholder(placeholderState = placeholderState), + transformation = SurfaceTransformation(transformationSpec), ) } } @@ -316,33 +295,32 @@ fun LibraryScreen( } @Composable -private fun IconWithBackground(resource: Int, contentDescription: String) { +private fun IconWithBackground(resource: Int, contentDescription: String, modifier: Modifier = Modifier) { Box( - modifier = Modifier + modifier = modifier .size(ButtonDefaults.LargeIconSize) .background( MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape + shape = CircleShape, ), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(id = resource), contentDescription = contentDescription, tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(ButtonDefaults.SmallIconSize) + modifier = Modifier.size(ButtonDefaults.SmallIconSize), ) } } @Composable -private fun QueueEmpty() { +private fun QueueEmptyText(modifier: Modifier = Modifier) { Text( text = stringResource(id = R.string.add_episode_to_queue), - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + modifier = modifier.padding(top = 8.dp, bottom = 8.dp), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall, - ) } @@ -351,7 +329,7 @@ private fun QueueEmpty() { @Composable fun LibraryScreenPreview( @PreviewParameter(WearPreviewEpisodes::class) - episode: PlayerEpisode + episode: PlayerEpisode, ) { LibraryScreen( columnState = rememberTransformingLazyColumnState(), @@ -361,7 +339,33 @@ fun LibraryScreenPreview( onYourPodcastClick = {}, onUpNextClick = {}, queue = listOf( - episode - ) + episode, + ), + placeholderState = rememberPlaceholderState(isVisible = false), ) } + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastContentPreview(@PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo, modifier: Modifier = Modifier) { + AppScaffold { + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.Button, + ) + + ScreenScaffold(contentPadding = contentPadding) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), + ) { + PodcastContent( + podcast = podcasts, + podcastArtworkPlaceholder = painterResource(id = R.drawable.music), + onClick = {}, + ) + } + } + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 01cc0f553f..dfdfdbe423 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -70,8 +70,8 @@ import com.google.android.horologist.images.base.paintable.DrawableResPaintable import com.google.android.horologist.images.coil.CoilPaintable import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement import com.google.android.horologist.media.ui.material3.components.PodcastControlButtons -import com.google.android.horologist.media.ui.material3.components.background.ArtworkImageBackground import com.google.android.horologist.media.ui.material3.components.animated.MarqueeTextMediaDisplay +import com.google.android.horologist.media.ui.material3.components.background.ArtworkImageBackground import com.google.android.horologist.media.ui.material3.components.display.LoadingMediaDisplay import com.google.android.horologist.media.ui.material3.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.material3.screens.player.PlayerScreen @@ -91,7 +91,7 @@ fun PlayerScreen( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, - modifier = modifier + modifier = modifier, ) } @@ -117,7 +117,7 @@ private fun PlayerScreen( TextMediaDisplay( title = stringResource(R.string.nothing_playing), subtitle = "", - titleIcon = DrawableResPaintable(R.drawable.ic_logo) + titleIcon = DrawableResPaintable(R.drawable.ic_logo), ) }, controlButtons = { @@ -129,7 +129,7 @@ private fun PlayerScreen( onSeekBackButtonClick = playerScreenViewModel::onRewindBy, seekBackButtonEnabled = false, onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, - seekForwardButtonEnabled = false + seekForwardButtonEnabled = false, ) }, buttons = { @@ -141,6 +141,7 @@ private fun PlayerScreen( enabled = false, ) }, + modifier = modifier, ) } @@ -162,13 +163,13 @@ private fun PlayerScreen( exoPlayer.release() } } - Box { + Box(modifier = modifier) { PlayerSurface( player = exoPlayer, modifier = Modifier.resizeWithContentScale( contentScale = ContentScale.Fit, - sourceSizeDp = null - ) + sourceSizeDp = null, + ), ) PlayerScreen( mediaDisplay = { @@ -176,42 +177,50 @@ private fun PlayerScreen( MarqueeTextMediaDisplay( title = episode.title, artist = episode.podcastName, - titleIcon = DrawableResPaintable(R.drawable.ic_logo) + titleIcon = DrawableResPaintable(R.drawable.ic_logo), ) } else { TextMediaDisplay( title = stringResource(R.string.nothing_playing), subtitle = "", - titleIcon = DrawableResPaintable(R.drawable.ic_logo) + titleIcon = DrawableResPaintable(R.drawable.ic_logo), ) } }, controlButtons = { PodcastControlButtons( - onPlayButtonClick = ({ - playerScreenViewModel.onPlay() - exoPlayer.play() - }), - onPauseButtonClick = ({ - playerScreenViewModel.onPause() - exoPlayer.pause() - }), + onPlayButtonClick = ( + { + playerScreenViewModel.onPlay() + exoPlayer.play() + } + ), + onPauseButtonClick = ( + { + playerScreenViewModel.onPause() + exoPlayer.pause() + } + ), playPauseButtonEnabled = true, playing = state.playerState.episodePlayerState.isPlaying, - onSeekBackButtonClick = ({ - playerScreenViewModel.onRewindBy() - exoPlayer.seekBack() - }), + onSeekBackButtonClick = ( + { + playerScreenViewModel.onRewindBy() + exoPlayer.seekBack() + } + ), seekBackButtonEnabled = true, - onSeekForwardButtonClick = ({ - playerScreenViewModel.onAdvanceBy() - exoPlayer.seekForward() - }), + onSeekForwardButtonClick = ( + { + playerScreenViewModel.onAdvanceBy() + exoPlayer.seekForward() + } + ), seekForwardButtonEnabled = true, seekBackButtonIncrement = SeekButtonIncrement.Ten, seekForwardButtonIncrement = SeekButtonIncrement.Ten, - trackPositionUiModel = state.playerState.trackPositionUiModel + trackPositionUiModel = state.playerState.trackPositionUiModel, ) }, buttons = { @@ -219,35 +228,37 @@ private fun PlayerScreen( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, playerUiState = state.playerState, - onPlaybackSpeedChange = ({ - playerScreenViewModel.onPlaybackSpeedChange() - if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( - 1 + onPlaybackSpeedChange = ( + { + playerScreenViewModel.onPlaybackSpeedChange() + if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( + 1, + ) ) - ) - exoPlayer.setPlaybackSpeed(1.5F) - else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofMillis( - 1500 + exoPlayer.setPlaybackSpeed(1.5F) + else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofMillis( + 1500, + ) ) - ) - exoPlayer.setPlaybackSpeed(2.0F) - else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( - 2 + exoPlayer.setPlaybackSpeed(2.0F) + else if (state.playerState.episodePlayerState.playbackSpeed == Duration.ofSeconds( + 2, + ) ) - ) - exoPlayer.setPlaybackSpeed(1.0F) - }), + exoPlayer.setPlaybackSpeed(1.0F) + } + ), enabled = true, ) }, - modifier = modifier + modifier = Modifier .requestFocusOnHierarchyActive() .rotaryScrollable( volumeRotaryBehavior( volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = { onUpdateVolume } + onRotaryVolumeInput = { onUpdateVolume }, ), - focusRequester = focusRequester + focusRequester = focusRequester, ), background = { ArtworkImageBackground( @@ -255,25 +266,21 @@ private fun PlayerScreen( colorScheme = MaterialTheme.colorScheme, modifier = Modifier.fillMaxSize(), ) - } + }, ) } } } } - - @androidx.annotation.OptIn(UnstableApi::class) - @Composable - internal fun rememberPlayer( - context: Context, - ) = remember { - ExoPlayer.Builder(context).setSeekForwardIncrementMs(10000).setSeekBackIncrementMs(10000) - .setMediaSourceFactory( - ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) - ).setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING).build().apply { - playWhenReady = true - repeatMode = Player.REPEAT_MODE_ALL - } - } - +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun rememberPlayer(context: Context) = remember { + ExoPlayer.Builder(context).setSeekForwardIncrementMs(10000).setSeekBackIncrementMs(10000) + .setMediaSourceFactory( + ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)), + ).setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING).build().apply { + playWhenReady = true + repeatMode = Player.REPEAT_MODE_ALL + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt index a4d25b69c2..991e6485c7 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -35,21 +35,21 @@ import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes -import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent -import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewEpisodes -import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding -import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun PodcastDetailsScreen( onPlayButtonClick: () -> Unit, @@ -59,9 +59,11 @@ import com.google.android.horologist.images.base.util.rememberVectorPainter podcastDetailsViewModel: PodcastDetailsViewModel = hiltViewModel(), ) { val uiState by podcastDetailsViewModel.uiState.collectAsStateWithLifecycle() + val placeholderState = rememberPlaceholderState(isVisible = uiState is PodcastDetailsScreenState.Loading) PodcastDetailsScreen( uiState = uiState, + placeholderState = placeholderState, onEpisodeItemClick = onEpisodeItemClick, onPlayEpisode = podcastDetailsViewModel::onPlayEpisodes, onDismiss = onDismiss, @@ -73,16 +75,17 @@ import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun PodcastDetailsScreen( uiState: PodcastDetailsScreenState, + placeholderState: PlaceholderState, onPlayButtonClick: () -> Unit, - modifier: Modifier = Modifier, onEpisodeItemClick: (PlayerEpisode) -> Unit, onPlayEpisode: (List) -> Unit, onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() @@ -90,7 +93,7 @@ fun PodcastDetailsScreen( ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { when (uiState) { is PodcastDetailsScreenState.Loaded -> { @@ -102,7 +105,7 @@ fun PodcastDetailsScreen( onEpisodeItemClick, columnState, contentPadding, - modifier + placeholderState = placeholderState, ) } @@ -114,7 +117,16 @@ fun PodcastDetailsScreen( ) } PodcastDetailsScreenState.Loading -> { - PodcastDetailScreenLoading(columnState, contentPadding, modifier) + PodcastDetailScreenLoaded( + emptyList(), + "", + { }, + { }, + { }, + columnState, + contentPadding, + placeholderState = placeholderState, + ) } } } @@ -129,18 +141,20 @@ fun PodcastDetailScreenLoaded( onEpisodeItemClick: (PlayerEpisode) -> Unit, columnState: TransformingLazyColumnState, contentPadding: PaddingValues, - modifier: Modifier = Modifier + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( modifier = modifier, state = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, ) { item { ListHeader { Text( text = title, maxLines = 1, - overflow = TextOverflow.Ellipsis, modifier = modifier + overflow = TextOverflow.Ellipsis, + modifier = Modifier.placeholder(placeholderState), ) } } @@ -148,55 +162,28 @@ fun PodcastDetailScreenLoaded( ButtonsContent( episodes = episodeList, onPlayButtonClick = onPlayButtonClick, - onPlayEpisode = onPlayEpisode + onPlayEpisode = onPlayEpisode, + placeholderState = placeholderState, ) } items(episodeList) { episode -> MediaContent( episode = episode, episodeArtworkPlaceholder = painterResource(id = R.drawable.music), - onEpisodeItemClick + onItemClick = onEpisodeItemClick, ) } } } -@Composable -fun PodcastDetailScreenLoading( - columnState: TransformingLazyColumnState, - contentPadding: PaddingValues, - modifier: Modifier = Modifier -) { - TransformingLazyColumn( - modifier = modifier, - state = columnState, - contentPadding = contentPadding - ) { - item { - ListHeader { - Text(text = stringResource(id = R.string.loading)) - } - } - item { - ButtonsContent( - episodes = emptyList(), - onPlayButtonClick = {}, - onPlayEpisode = {}, - enabled = false - ) - } - items(count = 2) { - PlaceholderButton() - } - } -} @Composable fun ButtonsContent( episodes: List, onPlayButtonClick: () -> Unit, onPlayEpisode: (List) -> Unit, + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, enabled: Boolean = true, - modifier: Modifier = Modifier ) { Button( @@ -208,25 +195,16 @@ fun ButtonsContent( icon = { Icon( painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) + contentDescription = stringResource(id = R.string.button_play_content_description), ) }, - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth() + .placeholder(placeholderState = placeholderState), ) { Text(stringResource(id = R.string.button_play_content_description)) } } -@ExperimentalHorologistApi -sealed class PodcastDetailsScreenState { - - data object Loading : PodcastDetailsScreenState() - - data class Loaded(val episodeList: List, val podcast: PodcastInfo) : PodcastDetailsScreenState() - - data object Empty : PodcastDetailsScreenState() -} - @WearPreviewDevices @WearPreviewFontScales @Composable @@ -243,21 +221,6 @@ fun PodcastDetailsScreenLoadedPreview( onEpisodeItemClick = {}, onPlayEpisode = {}, onDismiss = {}, - ) -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun PodcastDetailsScreenLoadingPreview( - @PreviewParameter(WearPreviewEpisodes::class) - episode: PlayerEpisode, -) { - PodcastDetailsScreen( - uiState = PodcastDetailsScreenState.Loading, - onPlayButtonClick = { }, - onEpisodeItemClick = {}, - onPlayEpisode = {}, - onDismiss = {}, + placeholderState = rememberPlaceholderState(isVisible = false), ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt index cbc2b96ce8..34378de659 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -36,14 +36,15 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PodcastInfo import com.example.jetcaster.core.model.asExternalModel import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.core.player.model.toPlayerEpisode import com.example.jetcaster.ui.PodcastDetails +import com.google.android.horologist.annotations.ExperimentalHorologistApi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted @@ -115,3 +116,13 @@ class PodcastDetailsViewModel @Inject constructor( episodePlayer.play(episodes) } } + +@ExperimentalHorologistApi +sealed class PodcastDetailsScreenState { + + data object Loading : PodcastDetailsScreenState() + + data class Loaded(val episodeList: List, val podcast: PodcastInfo) : PodcastDetailsScreenState() + + data object Empty : PodcastDetailsScreenState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt index 3d270ec8b2..bf52a452f4 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt @@ -24,10 +24,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -42,27 +40,30 @@ import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.ButtonDefaults import androidx.wear.compose.material3.FilledTonalButton import androidx.wear.compose.material3.ListHeader -import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import coil.compose.AsyncImage import com.example.jetcaster.R import com.example.jetcaster.core.model.PodcastInfo -import com.example.jetcaster.ui.components.PlaceholderButton import com.example.jetcaster.ui.preview.WearPreviewPodcasts import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding -import com.google.android.horologist.images.base.util.rememberVectorPainter @Composable fun PodcastsScreen( podcastsViewModel: PodcastsViewModel = hiltViewModel(), onPodcastsItemClick: (PodcastInfo) -> Unit, onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val uiState by podcastsViewModel.uiState.collectAsStateWithLifecycle() + val placeholderState = rememberPlaceholderState(isVisible = uiState is PodcastsScreenState.Loading) val modifiedState = when (uiState) { is PodcastsScreenState.Loaded -> { @@ -83,12 +84,15 @@ fun PodcastsScreen( podcastsScreenState = modifiedState, onPodcastsItemClick = onPodcastsItemClick, onDismiss = onDismiss, + placeholderState = placeholderState, + modifier = modifier, ) } @Composable fun PodcastsScreen( podcastsScreenState: PodcastsScreenState, + placeholderState: PlaceholderState, onPodcastsItemClick: (PodcastInfo) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -97,12 +101,12 @@ fun PodcastsScreen( val columnState = rememberTransformingLazyColumnState() val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { when (podcastsScreenState) { is PodcastsScreenState.Loaded -> PodcastScreenLoaded( @@ -110,15 +114,17 @@ fun PodcastsScreen( onPodcastsItemClick = onPodcastsItemClick, columnState = columnState, contentPadding = contentPadding, - modifier = modifier + placeholderState = placeholderState, ) PodcastsScreenState.Empty -> PodcastScreenEmpty(onDismiss) PodcastsScreenState.Loading -> - PodcastScreenLoading( + PodcastScreenLoaded( + podcastList = emptyList(), + onPodcastsItemClick = { }, columnState = columnState, contentPadding = contentPadding, - modifier = modifier + placeholderState = placeholderState, ) } } @@ -128,25 +134,28 @@ fun PodcastsScreen( fun PodcastScreenLoaded( podcastList: List, onPodcastsItemClick: (PodcastInfo) -> Unit, - modifier: Modifier = Modifier, contentPadding: PaddingValues, - columnState: TransformingLazyColumnState + columnState: TransformingLazyColumnState, + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( modifier = modifier, state = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, ) { item { ListHeader { - Text(text = stringResource(id = R.string.podcasts)) + Text( + text = stringResource(id = R.string.podcasts), + modifier = Modifier.placeholder(placeholderState), + ) } } - items(count = podcastList.size) { - index -> + items(count = podcastList.size) { index -> MediaContent( podcast = podcastList[index], - onPodcastsItemClick = onPodcastsItemClick + onPodcastsItemClick = onPodcastsItemClick, ) } @@ -159,84 +168,16 @@ fun PodcastScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { visible = true, title = { Text(stringResource(R.string.podcasts_no_podcasts)) }, onDismissRequest = { onDismiss }, - modifier = modifier - ) -} - -@Composable -fun PodcastScreenLoading( - modifier: Modifier = Modifier, - columnState: TransformingLazyColumnState, - contentPadding: PaddingValues -) { - - TransformingLazyColumn( modifier = modifier, - state = columnState, - contentPadding = contentPadding - ) { - item { - ListHeader(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.podcasts), - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 3, - ) - } - } - - items(count = 2) { - PlaceholderButton() - } - } -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun PodcastScreenLoadedPreview( - @PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo -) { - val columnState = rememberTransformingLazyColumnState() - val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.ListHeader, - last = ColumnItemType.Button - ) - PodcastScreenLoaded( - podcastList = listOf(podcasts), - onPodcastsItemClick = {}, - contentPadding = contentPadding, - columnState = columnState ) } -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun PodcastScreenLoadingPreview() { - val columnState = rememberTransformingLazyColumnState() - val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.ListHeader, - last = ColumnItemType.Button - ) - PodcastScreenLoading(columnState = columnState, contentPadding = contentPadding) -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun PodcastScreenEmptyPreview() { - PodcastScreenEmpty(onDismiss = {}) -} - @Composable fun MediaContent( podcast: PodcastInfo, - episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), onPodcastsItemClick: (PodcastInfo) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music), ) { val mediaTitle = podcast.title val secondaryLabel = podcast.author @@ -246,7 +187,7 @@ fun MediaContent( Text( mediaTitle, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Start + textAlign = TextAlign.Start, ) }, onClick = { onPodcastsItemClick(podcast) }, @@ -258,15 +199,40 @@ fun MediaContent( error = episodeArtworkPlaceholder, placeholder = episodeArtworkPlaceholder, contentScale = ContentScale.Crop, - modifier = modifier + modifier = Modifier .size( - ButtonDefaults.LargeIconSize + ButtonDefaults.LargeIconSize, ) - .clip(CircleShape) + .clip(CircleShape), ) }, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) } + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenLoadedPreview(@PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo) { + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button, + ) + PodcastScreenLoaded( + podcastList = listOf(podcasts), + onPodcastsItemClick = {}, + contentPadding = contentPadding, + columnState = columnState, + placeholderState = rememberPlaceholderState(isVisible = false), + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenEmptyPreview() { + PodcastScreenEmpty(onDismiss = {}) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt index 3c1d866c18..7934f93619 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt @@ -41,17 +41,20 @@ import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.ButtonGroup import androidx.wear.compose.material3.FilledIconButton import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButtonShapes import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.PlaceholderState import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.example.jetcaster.R import com.example.jetcaster.core.player.model.PlayerEpisode import com.example.jetcaster.ui.components.MediaContent -import com.example.jetcaster.ui.components.PlaceholderButton -import com.example.jetcaster.ui.components.PlayIconShape import com.example.jetcaster.ui.preview.WearPreviewEpisodes import com.google.android.horologist.compose.layout.ColumnItemType import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding @@ -64,10 +67,12 @@ import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadd queueViewModel: QueueViewModel = hiltViewModel(), ) { val uiState by queueViewModel.uiState.collectAsStateWithLifecycle() + val placeholderState = rememberPlaceholderState(isVisible = uiState is QueueScreenState.Loading) QueueScreen( uiState = uiState, onPlayButtonClick = onPlayButtonClick, + placeholderState = placeholderState, onPlayEpisodes = queueViewModel::onPlayEpisodes, modifier = modifier, onEpisodeItemClick = onEpisodeItemClick, @@ -79,23 +84,24 @@ import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadd @Composable fun QueueScreen( uiState: QueueScreenState, + placeholderState: PlaceholderState, onPlayButtonClick: () -> Unit, onPlayEpisodes: (List) -> Unit, - modifier: Modifier = Modifier, onEpisodeItemClick: (PlayerEpisode) -> Unit, onDeleteQueueEpisodes: () -> Unit, onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) val columnState = rememberTransformingLazyColumnState() ScreenScaffold( scrollState = columnState, contentPadding = contentPadding, - modifier = modifier + modifier = modifier.placeholderShimmer(placeholderState), ) { contentPadding -> when (uiState) { is QueueScreenState.Loaded -> QueueScreenLoaded( @@ -105,9 +111,19 @@ fun QueueScreen( onDeleteQueueEpisodes = onDeleteQueueEpisodes, onEpisodeItemClick = onEpisodeItemClick, columnState = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, + placeholderState = placeholderState, + ) + QueueScreenState.Loading -> QueueScreenLoaded( + episodeList = emptyList(), + onPlayButtonClick = { }, + onPlayEpisodes = { }, + onDeleteQueueEpisodes = { }, + onEpisodeItemClick = { }, + columnState = columnState, + contentPadding = contentPadding, + placeholderState = placeholderState, ) - QueueScreenState.Loading -> QueueScreenLoading(columnState, contentPadding) QueueScreenState.Empty -> QueueScreenEmpty(onDismiss) } } @@ -122,16 +138,20 @@ fun QueueScreenLoaded( onEpisodeItemClick: (PlayerEpisode) -> Unit, columnState: TransformingLazyColumnState, contentPadding: PaddingValues, - modifier: Modifier = Modifier + placeholderState: PlaceholderState, + modifier: Modifier = Modifier, ) { TransformingLazyColumn( modifier = modifier, state = columnState, - contentPadding = contentPadding + contentPadding = contentPadding, ) { item { ListHeader { - Text(text = stringResource(R.string.queue)) + Text( + text = stringResource(R.string.queue), + modifier = Modifier.placeholder(placeholderState), + ) } } item { @@ -140,52 +160,19 @@ fun QueueScreenLoaded( onPlayButtonClick = onPlayButtonClick, onPlayEpisodes = onPlayEpisodes, onDeleteQueueEpisodes = onDeleteQueueEpisodes, + placeholderState = placeholderState, ) } items(episodeList) { episode -> MediaContent( episode = episode, episodeArtworkPlaceholder = painterResource(id = R.drawable.music), - onItemClick = onEpisodeItemClick + onItemClick = onEpisodeItemClick, ) } } } -@Composable -fun QueueScreenLoading( - columnState: TransformingLazyColumnState, - contentPadding: PaddingValues, - modifier: Modifier = Modifier -) { - TransformingLazyColumn( - modifier = modifier, - state = columnState, - contentPadding = contentPadding - ) { - item { - ListHeader { - Text( - text = stringResource(R.string.queue), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - item { - ButtonsContent( - episodes = emptyList(), - onPlayButtonClick = {}, - onPlayEpisodes = {}, - onDeleteQueueEpisodes = { }, - enabled = false, - ) - } - items(count = 2) { - PlaceholderButton() - } - } -} - @Composable fun QueueScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { AlertDialog( @@ -193,7 +180,7 @@ fun QueueScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) { onDismissRequest = { onDismiss() }, title = { Text(stringResource(R.string.display_nothing_in_queue)) }, text = { Text(stringResource(R.string.no_episodes_from_queue)) }, - modifier = modifier + modifier = modifier, ) } @@ -203,46 +190,51 @@ fun ButtonsContent( onPlayButtonClick: () -> Unit, onPlayEpisodes: (List) -> Unit, onDeleteQueueEpisodes: () -> Unit, - enabled: Boolean = true, + placeholderState: PlaceholderState, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { val interactionSource1 = remember { MutableInteractionSource() } val interactionSource2 = remember { MutableInteractionSource() } - Box(modifier = modifier - .padding(bottom = 16.dp) - .height(52.dp), - contentAlignment = Alignment.Center) { + Box( + modifier = modifier + .padding(bottom = 16.dp) + .height(52.dp), + contentAlignment = Alignment.Center, + ) { ButtonGroup(Modifier.fillMaxWidth()) { FilledIconButton( onClick = { onPlayButtonClick() onPlayEpisodes(episodes) }, - modifier = modifier + modifier = Modifier .weight(weight = 0.7F) - .animateWidth(interactionSource1), + .animateWidth(interactionSource1) + .placeholder(placeholderState = placeholderState), enabled = enabled, interactionSource = interactionSource1, - shapes = PlayIconShape() + shapes = IconButtonShapes(MaterialTheme.shapes.medium), ) { Icon( painter = painterResource(id = R.drawable.play), - contentDescription = stringResource(id = R.string.button_play_content_description) + contentDescription = stringResource(id = R.string.button_play_content_description), ) } FilledIconButton( onClick = onDeleteQueueEpisodes, - modifier = modifier + modifier = Modifier .weight(weight = 0.3F) - .animateWidth(interactionSource2), + .animateWidth(interactionSource2) + .placeholder(placeholderState = placeholderState), interactionSource = interactionSource2, - enabled = enabled + enabled = enabled, ) { Icon( painter = painterResource(id = R.drawable.delete), contentDescription = - stringResource(id = R.string.button_delete_queue_content_description), + stringResource(id = R.string.button_delete_queue_content_description), ) } } @@ -259,7 +251,7 @@ fun QueueScreenLoadedPreview( val columnState = rememberTransformingLazyColumnState() val contentPadding = rememberResponsiveColumnPadding( first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + last = ColumnItemType.Button, ) QueueScreenLoaded( episodeList = listOf(episode), @@ -268,19 +260,9 @@ fun QueueScreenLoadedPreview( onDeleteQueueEpisodes = { }, onEpisodeItemClick = { }, columnState = columnState, - contentPadding = contentPadding - ) -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun QueueScreenLoadingPreview() { - val contentPadding = rememberResponsiveColumnPadding( - first = ColumnItemType.ListHeader, - last = ColumnItemType.Button + contentPadding = contentPadding, + placeholderState = rememberPlaceholderState(isVisible = false), ) - QueueScreenLoading(rememberTransformingLazyColumnState(), contentPadding) } @WearPreviewDevices