diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 93b519c19..4e50672c9 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -71,7 +71,7 @@ The sample implements [Wear UX best practices for media apps][mediappsbestpracti - Display scrollbar on scrolling - Display the time on top of the screens -The sample is built using the [Media Toolkit][[mediatoolkit]] which is an open source +The sample is built using the [Media Toolkit][mediatoolkit] which is an open source project part of [Horologist][horologist] to ease the development of media apps on Wear OS built on top of Compose for Wear. It provides ready to use UI screens, such the [EntityScreen][entityscreen] that is used in this sample to implement many screens such as Podcast, LatestEpisodes and Queue. [Horologist][horologist] also provides @@ -81,7 +81,7 @@ For simplicity, this sample uses a mock Player which is reused across form facto if you want to see an advanced Media sample built on Compose that uses Exoplayer and plays media content, refer to the [Media Toolkit sample][mediatoolkitsample]. -The [official media app guidance for Wear OS][ [wearmediaguidance]] +The [official media app guidance for Wear OS][wearmediaguidance] advices to download content on the watch before listening to preserve power, this feature will be added to this sample in future iterations. You can refer to the [Media Toolkit sample][mediatoolkitsample] to learn how to implement the media download feature. diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 8b37162dd..52769ef16 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -131,6 +131,7 @@ dependencies { implementation projects.core.data implementation projects.core.designsystem implementation projects.core.domain + implementation projects.core.domainTesting // Testing testImplementation libs.androidx.compose.ui.test.junit4 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 8e06f5e51..a3e47c34f 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,43 +16,37 @@ package com.example.jetcaster.ui.podcasts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MusicNote 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.graphics.painter.Painter import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState -import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.ChipDefaults -import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text -import androidx.wear.compose.material.dialog.Alert -import androidx.wear.compose.material.dialog.Dialog +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.model.PodcastInfo +import com.example.jetcaster.podcasts.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.ScalingLazyColumnState import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.Button +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.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 @@ -80,45 +74,9 @@ fun PodcastsScreen( PodcastsScreen( podcastsScreenState = modifiedState, - onPodcastsItemClick = onPodcastsItemClick + onPodcastsItemClick = onPodcastsItemClick, + onDismiss = onDismiss ) - - Dialog( - showDialog = modifiedState == PodcastsScreenState.Empty, - onDismissRequest = onDismiss, - scrollState = rememberScalingLazyListState(), - ) { - Alert( - title = { - Text( - text = stringResource(R.string.podcasts_no_podcasts), - color = MaterialTheme.colors.onBackground, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.title3, - ) - }, - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - - Button( - imageVector = Icons.Default.Close, - contentDescription = stringResource( - id = R.string - .podcasts_failed_dialog_cancel_button_content_description, - ), - onClick = onDismiss, - modifier = Modifier - .size(24.dp) - .wrapContentSize(align = Alignment.Center), - colors = ButtonDefaults.secondaryButtonColors() - ) - } - } - } - } } @ExperimentalHorologistApi @@ -126,6 +84,7 @@ fun PodcastsScreen( fun PodcastsScreen( podcastsScreenState: PodcastsScreenState, onPodcastsItemClick: (PodcastInfo) -> Unit, + onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -135,40 +94,124 @@ fun PodcastsScreen( modifier = modifier ) { when (podcastsScreenState) { - is PodcastsScreenState.Loaded -> { - EntityScreen( - columnState = columnState, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding() - ) { - Text(text = stringResource(id = R.string.podcasts)) - } - }, - content = { - items(count = podcastsScreenState.podcastList.size) { - index -> - MediaContent( - podcast = podcastsScreenState.podcastList[index], - downloadItemArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onPodcastsItemClick = onPodcastsItemClick - - ) - } - } + is PodcastsScreenState.Loaded -> PodcastScreenLoaded( + columnState = columnState, + podcastList = podcastsScreenState.podcastList, + onPodcastsItemClick = onPodcastsItemClick + ) + PodcastsScreenState.Empty -> + PodcastScreenEmpty(onDismiss) + PodcastsScreenState.Loading -> + PodcastScreenLoading(columnState) + } + } +} + +@Composable +fun PodcastScreenLoaded( + columnState: ScalingLazyColumnState, + podcastList: List, + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { + EntityScreen( + columnState = columnState, + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + 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 + ) } - PodcastsScreenState.Empty, - PodcastsScreenState.Loading -> { - Column { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } + } + ) +} + +@Composable +fun PodcastScreenEmpty( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + showDialog = true, + message = stringResource(R.string.podcasts_no_podcasts), + onDismiss = onDismiss, + modifier = modifier + ) +} + +@Composable +fun PodcastScreenLoading( + columnState: ScalingLazyColumnState, + modifier: Modifier = Modifier +) { + EntityScreen( + columnState = columnState, + modifier = modifier, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(R.string.podcasts) + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) } } - } + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenLoadedPreview( + @PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo +) { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + PodcastScreenLoaded( + columnState = columnState, + podcastList = listOf(podcasts), + onPodcastsItemClick = {} + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenLoadingPreview() { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + PodcastScreenLoading(columnState) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenEmptyPreview() { + PodcastScreenEmpty(onDismiss = {}) } @Composable diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/WearPreviewPodcasts.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/WearPreviewPodcasts.kt new file mode 100644 index 000000000..a4c4a1ff9 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/WearPreviewPodcasts.kt @@ -0,0 +1,25 @@ +/* + * 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.podcasts +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.PodcastInfo + +public class WearPreviewPodcasts : PreviewParameterProvider { + public override val values: Sequence + get() = PreviewPodcasts.asSequence() +} 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 99b5ef77b..03d3457ac 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 @@ -31,12 +31,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter 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.items import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.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 @@ -44,6 +47,7 @@ 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.ScalingLazyColumnState import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberResponsiveColumnState import com.google.android.horologist.compose.material.AlertDialog @@ -55,6 +59,8 @@ 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 +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable fun QueueScreen( onPlayButtonClick: () -> Unit, @@ -97,72 +103,104 @@ fun QueueScreen( modifier = modifier ) { when (uiState) { - is QueueScreenState.Loaded -> { - EntityScreen( - columnState = columnState, - headerContent = { - ResponsiveListHeader( - contentPadding = ListHeaderDefaults.firstItemPadding() - ) { - Text(text = stringResource(R.string.queue)) - } - }, - buttonsContent = { - ButtonsContent( - episodes = uiState.episodeList, - onPlayButtonClick = onPlayButtonClick, - onPlayEpisodes = onPlayEpisodes, - onDeleteQueueEpisodes = onDeleteQueueEpisodes - ) - }, - content = { - items(uiState.episodeList) { episode -> - MediaContent( - episode = episode, - episodeArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onEpisodeItemClick - ) - } - } - ) + is QueueScreenState.Loaded -> QueueScreenLoaded( + columnState = columnState, + episodeList = uiState.episodeList, + onDeleteQueueEpisodes = onDeleteQueueEpisodes, + onPlayEpisodes = onPlayEpisodes, + onPlayButtonClick = onPlayButtonClick, + onEpisodeItemClick = onEpisodeItemClick + ) + QueueScreenState.Loading -> QueueScreenLoading(columnState) + QueueScreenState.Empty -> QueueScreenEmpty(onDismiss) + } + } +} + +@Composable +fun QueueScreenLoaded( + columnState: ScalingLazyColumnState, + episodeList: List, + onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List) -> Unit, + onDeleteQueueEpisodes: () -> Unit, + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier +) { + EntityScreen( + columnState = columnState, + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(R.string.queue)) } - QueueScreenState.Loading -> { - EntityScreen( - columnState = columnState, - headerContent = { - DefaultEntityScreenHeader( - title = stringResource(R.string.queue) - ) - }, - buttonsContent = { - ButtonsContent( - episodes = emptyList(), - onPlayButtonClick = {}, - onPlayEpisodes = {}, - onDeleteQueueEpisodes = { }, - enabled = false - ) - }, - content = { - items(count = 2) { - PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) - } - } + }, + buttonsContent = { + 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, + ), + onEpisodeItemClick ) } - QueueScreenState.Empty -> { - AlertDialog( - showDialog = true, - onDismiss = onDismiss, - title = stringResource(R.string.display_nothing_in_queue), - message = stringResource(R.string.no_episodes_from_queue) - ) + } + ) +} + +@Composable +fun QueueScreenLoading( + columnState: ScalingLazyColumnState, + modifier: Modifier = Modifier +) { + EntityScreen( + columnState = columnState, + modifier = modifier, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(R.string.queue) + ) + }, + buttonsContent = { + ButtonsContent( + episodes = emptyList(), + onPlayButtonClick = {}, + onPlayEpisodes = {}, + onDeleteQueueEpisodes = { }, + enabled = false + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) } } - } + ) +} + +@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) + ) } @OptIn(ExperimentalHorologistApi::class) @@ -172,11 +210,12 @@ fun ButtonsContent( onPlayButtonClick: () -> Unit, onPlayEpisodes: (List) -> Unit, onDeleteQueueEpisodes: () -> Unit, - enabled: Boolean = true + enabled: Boolean = true, + modifier: Modifier = Modifier ) { Row( - modifier = Modifier + modifier = modifier .padding(bottom = 16.dp) .height(52.dp), verticalAlignment = Alignment.CenterVertically, @@ -209,13 +248,28 @@ fun ButtonsContent( fun MediaContent( episode: PlayerEpisode, episodeArtworkPlaceholder: Painter?, - onEpisodeItemClick: (EpisodeToPodcast) -> Unit + onEpisodeItemClick: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier ) { val mediaTitle = episode.title + val duration = episode.duration - val secondaryLabel = episode.author + val secondaryLabel = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + } Chip( + modifier = modifier, label = mediaTitle, onClick = { onEpisodeItemClick }, secondaryLabel = secondaryLabel, @@ -224,3 +278,49 @@ fun MediaContent( colors = ChipDefaults.secondaryChipColors(), ) } + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenLoadedPreview(@PreviewParameter(WearPreviewQueue::class) episode: PlayerEpisode,) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + QueueScreenLoaded( + columnState = columnState, + episodeList = listOf(episode), + onPlayButtonClick = { }, + onPlayEpisodes = { }, + onEpisodeItemClick = { }, + onDeleteQueueEpisodes = { } + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenLoadingPreview() { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + QueueScreenLoading( + columnState = columnState, + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenEmptyPreview() { + QueueScreenEmpty(onDismiss = {}) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/WearPreviewQueue.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/WearPreviewQueue.kt new file mode 100644 index 000000000..29e7043ee --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/WearPreviewQueue.kt @@ -0,0 +1,33 @@ +/* + * 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.queue + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.player.model.PlayerEpisode + +public class WearPreviewQueue : PreviewParameterProvider { + public override val values: Sequence + get() = PreviewEpisodes.map { + PlayerEpisode( + uri = it.uri, + author = it.author, + title = it.title, + published = it.published + ) + }.asSequence() +}