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 6869c112c6..be1b6201f8 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -45,8 +45,10 @@ import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.YourPodcasts import com.example.jetcaster.ui.home.HomeScreen import com.example.jetcaster.ui.library.LatestEpisodesScreen +import com.example.jetcaster.ui.library.PodcastsScreen import com.example.jetcaster.ui.player.PlayerScreen import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer @@ -88,7 +90,6 @@ fun WearApp() { mediaEntityScreen = {}, playlistsScreen = {}, settingsScreen = {}, - navHostState = navHostState, snackbarViewModel = snackbarViewModel, volumeViewModel = volumeViewModel, @@ -108,6 +109,12 @@ fun WearApp() { } ) } + composable(route = YourPodcasts.navRoute) { + PodcastsScreen( + onPodcastsItemClick = { navController.navigateToPlayer() }, + onErrorDialogCancelClick = { navController.popBackStack() } + ) + } }, ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index b1851dc961..513a83b0fe 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -119,10 +119,6 @@ class HomeViewModel @Inject constructor( } } - fun onHomeCategorySelected(category: HomeCategory) { - selectedHomeCategory.value = category - } - fun onPodcastUnfollowed(podcastUri: String) { viewModelScope.launch { podcastStore.unfollowPodcast(podcastUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt new file mode 100644 index 0000000000..a78da459e7 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsScreen.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2024 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.library + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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 com.example.jetcaster.R +import com.example.jetcaster.core.data.model.PodcastInfo +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.composables.Section +import com.google.android.horologist.composables.SectionedList +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberColumnState +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.Title +import com.google.android.horologist.images.coil.CoilPaintable + +@Composable +fun PodcastsScreen( + podcastsViewModel: PodcastsViewModel = hiltViewModel(), + onPodcastsItemClick: (PodcastInfo) -> Unit, + onErrorDialogCancelClick: () -> Unit, +) { + val uiState by podcastsViewModel.uiState.collectAsStateWithLifecycle() + + val modifiedState = when (uiState) { + is PodcastsScreenState.Loaded -> { + val modifiedPodcast = (uiState as PodcastsScreenState.Loaded).podcastList.map { + it.takeIf { it.title.isNotEmpty() } + ?: it.copy(title = stringResource(id = R.string.no_title)) + } + + PodcastsScreenState.Loaded(modifiedPodcast) + } + + PodcastsScreenState.Empty, + PodcastsScreenState.Loading, + -> uiState + } + + PodcastsScreen( + podcastsScreenState = modifiedState, + onPodcastsItemClick = onPodcastsItemClick + ) + + Dialog( + showDialog = modifiedState == PodcastsScreenState.Empty, + onDismissRequest = onErrorDialogCancelClick, + 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 = onErrorDialogCancelClick, + modifier = Modifier + .size(24.dp) + .wrapContentSize(align = Alignment.Center), + colors = ButtonDefaults.secondaryButtonColors() + ) + } + } + } + } +} + +@ExperimentalHorologistApi +@Composable +fun PodcastsScreen( + podcastsScreenState: PodcastsScreenState, + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + podcastItemArtworkPlaceholder: Painter? = null, +) { + + val podcastContent: @Composable (podcast: PodcastInfo) -> Unit = { podcast -> + Chip( + label = podcast.title, + onClick = { onPodcastsItemClick(podcast) }, + icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) + } + + PodcastsScreen( + podcastsScreenState = podcastsScreenState, + modifier = modifier, + content = { podcast -> + Chip( + label = podcast.title, + onClick = { onPodcastsItemClick(podcast) }, + icon = CoilPaintable(podcast.imageUrl, podcastItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) + } + ) +} + +@ExperimentalHorologistApi +@Composable +fun PodcastsScreen( + podcastsScreenState: PodcastsScreenState, + modifier: Modifier = Modifier, + content: @Composable (podcast: T) -> Unit, +) { + val columnState = rememberColumnState() + ScreenScaffold(scrollState = columnState) { + SectionedList( + modifier = modifier, + columnState = columnState, + ) { + val sectionState = when (podcastsScreenState) { + is PodcastsScreenState.Loaded -> { + Section.State.Loaded(podcastsScreenState.podcastList) + } + + PodcastsScreenState.Empty -> Section.State.Failed + PodcastsScreenState.Loading -> Section.State.Loading + } + + section(state = sectionState) { + header { + Title( + R.string.podcasts, + Modifier.padding(bottom = 12.dp), + ) + } + + loaded { content(it) } + + loading(count = 4) { + Column { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + } + } + } +} + +@ExperimentalHorologistApi +public sealed class PodcastsScreenState { + + public object Loading : PodcastsScreenState() + + public data class Loaded( + val podcastList: List, + ) : PodcastsScreenState() + + public object Empty : PodcastsScreenState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt new file mode 100644 index 0000000000..e7bb50fb85 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/PodcastsViewModel.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 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.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.model.PodcastInfo +import com.example.jetcaster.core.data.model.asExternalModel +import com.example.jetcaster.core.data.repository.PodcastStore +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PodcastsViewModel @Inject constructor( + podcastStore: PodcastStore, +) : ViewModel() { + + val uiState: StateFlow> = + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10).map { + if (it.isNotEmpty()) { + PodcastsScreenState.Loaded(it.map(PodcastMapper::map)) + } else { + PodcastsScreenState.Empty + } + }.catch { + emit(PodcastsScreenState.Empty) + }.stateIn( + viewModelScope, + started = SharingStarted.Eagerly, + initialValue = PodcastsScreenState.Loading, + ) +} + +object PodcastMapper { + + /** + * Maps from [Podcast]. + */ + fun map( + podcastWithExtraInfo: PodcastWithExtraInfo, + ): PodcastInfo = + podcastWithExtraInfo.asExternalModel() +} diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index b66e8ab5b5..ec7f044650 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -60,4 +60,8 @@ Following Not following Nothing playing + + No podcasts available at the moment + No title + Cancel