Skip to content

Instantly share code, notes, and snippets.

@mikescamell
Created June 6, 2025 08:25
Show Gist options
  • Save mikescamell/2d85ebffbd864e88aad5cd09717fc355 to your computer and use it in GitHub Desktop.
Save mikescamell/2d85ebffbd864e88aad5cd09717fc355 to your computer and use it in GitHub Desktop.
This file has been truncated, but you can view the full file.
Directory structure:
└── android-compose-samples/
├── README.md
├── Jetcaster/
│ ├── README.md
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── core/
│ │ ├── data/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ └── core/
│ │ │ ├── data/
│ │ │ │ ├── Dispatcher.kt
│ │ │ │ ├── database/
│ │ │ │ │ ├── DateTimeTypeConverters.kt
│ │ │ │ │ ├── JetcasterDatabase.kt
│ │ │ │ │ ├── dao/
│ │ │ │ │ │ ├── BaseDao.kt
│ │ │ │ │ │ ├── CategoriesDao.kt
│ │ │ │ │ │ ├── EpisodesDao.kt
│ │ │ │ │ │ ├── PodcastCategoryEntryDao.kt
│ │ │ │ │ │ ├── PodcastFollowedEntryDao.kt
│ │ │ │ │ │ ├── PodcastsDao.kt
│ │ │ │ │ │ └── TransactionRunnerDao.kt
│ │ │ │ │ └── model/
│ │ │ │ │ ├── Category.kt
│ │ │ │ │ ├── Episode.kt
│ │ │ │ │ ├── EpisodeToPodcast.kt
│ │ │ │ │ ├── Podcast.kt
│ │ │ │ │ ├── PodcastCategoryEntry.kt
│ │ │ │ │ ├── PodcastFollowedEntry.kt
│ │ │ │ │ └── PodcastWithExtraInfo.kt
│ │ │ │ ├── di/
│ │ │ │ │ └── DataDiModule.kt
│ │ │ │ ├── network/
│ │ │ │ │ ├── Feeds.kt
│ │ │ │ │ ├── OkHttpExtensions.kt
│ │ │ │ │ └── PodcastFetcher.kt
│ │ │ │ └── repository/
│ │ │ │ ├── CategoryStore.kt
│ │ │ │ ├── EpisodeStore.kt
│ │ │ │ ├── PodcastsRepository.kt
│ │ │ │ └── PodcastStore.kt
│ │ │ └── util/
│ │ │ └── Flows.kt
│ │ ├── data-testing/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ └── core/
│ │ │ └── data/
│ │ │ └── testing/
│ │ │ └── repository/
│ │ │ ├── TestCategoryStore.kt
│ │ │ ├── TestEpisodeStore.kt
│ │ │ └── TestPodcastStore.kt
│ │ ├── designsystem/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── java/
│ │ │ │ └── com/
│ │ │ │ └── example/
│ │ │ │ └── jetcaster/
│ │ │ │ └── designsystem/
│ │ │ │ ├── component/
│ │ │ │ │ ├── HtmlTextContainer.kt
│ │ │ │ │ ├── ImageBackground.kt
│ │ │ │ │ ├── PodcastImage.kt
│ │ │ │ │ └── thumbnailPlaceholder.kt
│ │ │ │ └── theme/
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Keylines.kt
│ │ │ │ ├── Shape.kt
│ │ │ │ ├── Type.kt
│ │ │ │ └── Typography.kt
│ │ │ └── res/
│ │ │ ├── values/
│ │ │ │ └── colors.xml
│ │ │ └── values-night/
│ │ │ └── colors.xml
│ │ ├── domain/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── main/
│ │ │ │ ├── AndroidManifest.xml
│ │ │ │ └── java/
│ │ │ │ └── com/
│ │ │ │ └── example/
│ │ │ │ └── jetcaster/
│ │ │ │ └── core/
│ │ │ │ ├── di/
│ │ │ │ │ └── DomainDiModule.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── FilterableCategoriesUseCase.kt
│ │ │ │ │ ├── GetLatestFollowedEpisodesUseCase.kt
│ │ │ │ │ └── PodcastCategoryFilterUseCase.kt
│ │ │ │ ├── model/
│ │ │ │ │ ├── CategoryInfo.kt
│ │ │ │ │ ├── EpisodeInfo.kt
│ │ │ │ │ ├── FilterableCategoriesModel.kt
│ │ │ │ │ ├── LibraryInfo.kt
│ │ │ │ │ ├── PodcastCategoryFilterResult.kt
│ │ │ │ │ ├── PodcastInfo.kt
│ │ │ │ │ └── PodcastToEpisodeInfo.kt
│ │ │ │ └── player/
│ │ │ │ ├── EpisodePlayer.kt
│ │ │ │ ├── MockEpisodePlayer.kt
│ │ │ │ └── model/
│ │ │ │ └── PlayerEpisode.kt
│ │ │ └── test/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ └── core/
│ │ │ └── domain/
│ │ │ ├── FilterableCategoriesUseCaseTest.kt
│ │ │ ├── GetLatestFollowedEpisodesUseCaseTest.kt
│ │ │ ├── PodcastCategoryFilterUseCaseTest.kt
│ │ │ └── player/
│ │ │ └── MockEpisodePlayerTest.kt
│ │ └── domain-testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── com/
│ │ └── example/
│ │ └── jetcaster/
│ │ └── core/
│ │ └── domain/
│ │ └── testing/
│ │ └── PreviewData.kt
│ ├── docs/
│ ├── glancewidget/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ └── glancewidget/
│ │ │ ├── Colors.kt
│ │ │ ├── JetcasterAppWidget.kt
│ │ │ └── JetcasterAppWidgetPreview.kt
│ │ └── res/
│ │ ├── layout/
│ │ │ └── widget_preview.xml
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── sizes.xml
│ │ │ ├── strings.xml
│ │ │ └── styles.xml
│ │ ├── values-h48dp/
│ │ │ └── sizes.xml
│ │ ├── values-night/
│ │ │ └── colors.xml
│ │ ├── values-night-v31/
│ │ │ └── colors.xml
│ │ ├── values-v31/
│ │ │ ├── colors.xml
│ │ │ └── styles.xml
│ │ └── xml/
│ │ └── jetcaster_info.xml
│ ├── mobile/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ ├── JetcasterApplication.kt
│ │ │ ├── ui/
│ │ │ │ ├── JetcasterApp.kt
│ │ │ │ ├── JetcasterAppState.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── home/
│ │ │ │ │ ├── Home.kt
│ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ ├── category/
│ │ │ │ │ │ └── PodcastCategory.kt
│ │ │ │ │ ├── discover/
│ │ │ │ │ │ └── Discover.kt
│ │ │ │ │ └── library/
│ │ │ │ │ └── Library.kt
│ │ │ │ ├── player/
│ │ │ │ │ ├── PlayerScreen.kt
│ │ │ │ │ └── PlayerViewModel.kt
│ │ │ │ ├── podcast/
│ │ │ │ │ ├── PodcastDetailsScreen.kt
│ │ │ │ │ └── PodcastDetailsViewModel.kt
│ │ │ │ ├── shared/
│ │ │ │ │ ├── EpisodeListItem.kt
│ │ │ │ │ └── Loading.kt
│ │ │ │ ├── theme/
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ └── tooling/
│ │ │ │ └── DevicePreviews.kt
│ │ │ └── util/
│ │ │ ├── Buttons.kt
│ │ │ ├── Colors.kt
│ │ │ ├── GradientScrim.kt
│ │ │ ├── LazyVerticalGrid.kt
│ │ │ ├── PluralResources.kt
│ │ │ ├── ViewModel.kt
│ │ │ ├── WindowInfoUtil.kt
│ │ │ └── WindowSizeClass.kt
│ │ └── res/
│ │ └── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ ├── tv/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ └── tv/
│ │ │ ├── JetCasterTvApp.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── model/
│ │ │ │ ├── CategoryInfoList.kt
│ │ │ │ ├── CategorySelection.kt
│ │ │ │ ├── EpisodeList.kt
│ │ │ │ └── PodcastList.kt
│ │ │ └── ui/
│ │ │ ├── JetcasterApp.kt
│ │ │ ├── JetcasterAppState.kt
│ │ │ ├── component/
│ │ │ │ ├── Background.kt
│ │ │ │ ├── Button.kt
│ │ │ │ ├── ButtonWithIcon.kt
│ │ │ │ ├── Catalog.kt
│ │ │ │ ├── EpisodeCard.kt
│ │ │ │ ├── EpisodeDateAndDuration.kt
│ │ │ │ ├── EpisodeDetails.kt
│ │ │ │ ├── EpisodeRow.kt
│ │ │ │ ├── ErrorState.kt
│ │ │ │ ├── Loading.kt
│ │ │ │ ├── NotAvailableFeature.kt
│ │ │ │ ├── PodcastCard.kt
│ │ │ │ ├── Seekbar.kt
│ │ │ │ ├── Thumbnail.kt
│ │ │ │ └── TwoColumn.kt
│ │ │ ├── discover/
│ │ │ │ ├── DiscoverScreen.kt
│ │ │ │ └── DiscoverScreenViewModel.kt
│ │ │ ├── episode/
│ │ │ │ ├── EpisodeScreen.kt
│ │ │ │ └── EpisodeScreenViewModel.kt
│ │ │ ├── library/
│ │ │ │ ├── LibraryScreen.kt
│ │ │ │ └── LibraryScreenViewModel.kt
│ │ │ ├── player/
│ │ │ │ ├── PlayerScreen.kt
│ │ │ │ └── PlayerScreenViewModel.kt
│ │ │ ├── podcast/
│ │ │ │ ├── PodcastDetailsScreen.kt
│ │ │ │ └── PodcastDetailsScreenViewModel.kt
│ │ │ ├── profile/
│ │ │ │ └── ProfileScreen.kt
│ │ │ ├── search/
│ │ │ │ ├── SearchScreen.kt
│ │ │ │ └── SearchScreenViewModel.kt
│ │ │ ├── settings/
│ │ │ │ └── SettingsScreen.kt
│ │ │ └── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Space.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ └── res/
│ │ └── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── wear/
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetcaster/
│ │ │ ├── JetcasterWearApplication.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── WearApp.kt
│ │ │ ├── theme/
│ │ │ │ ├── ColorScheme.kt
│ │ │ │ ├── Shape.kt
│ │ │ │ ├── Type.kt
│ │ │ │ └── WearAppTheme.kt
│ │ │ └── ui/
│ │ │ ├── JetcasterNavController.kt
│ │ │ ├── components/
│ │ │ │ ├── MediaContent.kt
│ │ │ │ └── SettingsButtons.kt
│ │ │ ├── episode/
│ │ │ │ ├── EpisodeScreen.kt
│ │ │ │ └── EpisodeViewModel.kt
│ │ │ ├── latest_episodes/
│ │ │ │ ├── LatestEpisodesScreen.kt
│ │ │ │ └── LatestEpisodeViewModel.kt
│ │ │ ├── library/
│ │ │ │ ├── LibraryScreen.kt
│ │ │ │ └── LibraryViewModel.kt
│ │ │ ├── player/
│ │ │ │ ├── PlayerScreen.kt
│ │ │ │ └── PlayerViewModel.kt
│ │ │ ├── podcast/
│ │ │ │ ├── PodcastDetailsScreen.kt
│ │ │ │ └── PodcastDetailsViewModel.kt
│ │ │ ├── podcasts/
│ │ │ │ ├── PodcastsScreen.kt
│ │ │ │ └── PodcastsViewModel.kt
│ │ │ ├── preview/
│ │ │ │ ├── WearPreviewEpisodes.kt
│ │ │ │ └── WearPreviewPodcasts.kt
│ │ │ └── queue/
│ │ │ ├── QueueScreen.kt
│ │ │ └── QueueViewModel.kt
│ │ └── res/
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ └── values-round/
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── test/
│ └── java/
│ └── com/
│ └── example/
│ └── jetcaster/
│ └── NavigationTest.kt
├── Jetchat/
│ ├── README.md
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ └── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── androidTest/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── com/
│ │ └── example/
│ │ └── compose/
│ │ └── jetchat/
│ │ ├── ConversationTest.kt
│ │ ├── NavigationTest.kt
│ │ ├── UserInputTest.kt
│ │ └── Utils.kt
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ └── compose/
│ │ └── jetchat/
│ │ ├── MainViewModel.kt
│ │ ├── NavActivity.kt
│ │ ├── UiExtras.kt
│ │ ├── components/
│ │ │ ├── AnimatingFabContent.kt
│ │ │ ├── BaseLineHeightModifier.kt
│ │ │ ├── JetchatAppBar.kt
│ │ │ ├── JetchatDrawer.kt
│ │ │ ├── JetchatIcon.kt
│ │ │ └── JetchatScaffold.kt
│ │ ├── conversation/
│ │ │ ├── Conversation.kt
│ │ │ ├── ConversationFragment.kt
│ │ │ ├── ConversationUiState.kt
│ │ │ ├── JumpToBottom.kt
│ │ │ ├── MessageFormatter.kt
│ │ │ ├── RecordButton.kt
│ │ │ └── UserInput.kt
│ │ ├── data/
│ │ │ └── FakeData.kt
│ │ ├── profile/
│ │ │ ├── Previews.kt
│ │ │ ├── Profile.kt
│ │ │ ├── ProfileFragment.kt
│ │ │ └── ProfileViewModel.kt
│ │ ├── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Themes.kt
│ │ │ └── Typography.kt
│ │ └── widget/
│ │ ├── JetChatWidget.kt
│ │ ├── WidgetReceiver.kt
│ │ ├── composables/
│ │ │ └── MessagesWidget.kt
│ │ └── theme/
│ │ ├── Theme.kt
│ │ └── Type.kt
│ └── res/
│ ├── layout/
│ │ ├── content_main.xml
│ │ └── fragment_profile.xml
│ ├── menu/
│ │ └── activity_main_drawer.xml
│ ├── navigation/
│ │ └── mobile_navigation.xml
│ ├── values/
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── ids.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ ├── values-night/
│ │ ├── colors.xml
│ │ └── themes.xml
│ ├── values-v23/
│ │ ├── font_certs.xml
│ │ └── themes.xml
│ ├── values-v27/
│ │ └── themes.xml
│ └── xml/
│ └── widget_unread_messages_info.xml
├── JetLagged/
│ ├── README.md
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ └── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── androidTest/
│ │ └── java/
│ │ └── com/
│ │ └── example/
│ │ └── jetlagged/
│ │ └── AppTest.kt
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ └── jetlagged/
│ │ ├── HomeScreenCards.kt
│ │ ├── JetLaggedDrawer.kt
│ │ ├── JetLaggedScreen.kt
│ │ ├── MainActivity.kt
│ │ ├── backgrounds/
│ │ │ ├── BubbleBackground.kt
│ │ │ ├── FadingCircleBackground.kt
│ │ │ ├── SimpleGradientBackground.kt
│ │ │ ├── SolarFlareShaderBackground.kt
│ │ │ └── StripesShaderBackground.kt
│ │ ├── data/
│ │ │ ├── FakeHeartRateData.kt
│ │ │ ├── FakeSleepData.kt
│ │ │ ├── JetLaggedHomeScreenState.kt
│ │ │ └── JetLaggedHomeScreenViewModel.kt
│ │ ├── heartrate/
│ │ │ ├── HeartRateCard.kt
│ │ │ └── HeartRateGraph.kt
│ │ ├── sleep/
│ │ │ ├── JetLaggedHeader.kt
│ │ │ ├── JetLaggedHeaderTabs.kt
│ │ │ ├── JetLaggedTimeGraph.kt
│ │ │ ├── SleepBar.kt
│ │ │ ├── SleepData.kt
│ │ │ └── TimeGraph.kt
│ │ └── ui/
│ │ ├── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ └── util/
│ │ └── MultiDevicePreview.kt
│ └── res/
│ ├── values/
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── values-v23/
│ └── font_certs.xml
├── JetNews/
│ ├── README.md
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── app/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidTest/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetnews/
│ │ │ ├── HomeScreenTests.kt
│ │ │ ├── JetnewsTests.kt
│ │ │ ├── TestAppContainer.kt
│ │ │ └── TestHelper.kt
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── jetnews/
│ │ │ ├── JetnewsApplication.kt
│ │ │ ├── data/
│ │ │ │ ├── AppContainerImpl.kt
│ │ │ │ ├── Result.kt
│ │ │ │ ├── interests/
│ │ │ │ │ ├── InterestsRepository.kt
│ │ │ │ │ └── impl/
│ │ │ │ │ └── FakeInterestsRepository.kt
│ │ │ │ └── posts/
│ │ │ │ ├── PostsRepository.kt
│ │ │ │ └── impl/
│ │ │ │ ├── BlockingFakePostsRepository.kt
│ │ │ │ ├── FakePostsRepository.kt
│ │ │ │ └── PostsData.kt
│ │ │ ├── glance/
│ │ │ │ ├── JetnewsGlanceAppWidgetReceiver.kt
│ │ │ │ └── ui/
│ │ │ │ ├── Divider.kt
│ │ │ │ ├── JetnewsGlanceAppWidget.kt
│ │ │ │ ├── Post.kt
│ │ │ │ └── theme/
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ ├── model/
│ │ │ │ ├── Post.kt
│ │ │ │ └── PostsFeed.kt
│ │ │ ├── ui/
│ │ │ │ ├── AppDrawer.kt
│ │ │ │ ├── JetnewsApp.kt
│ │ │ │ ├── JetnewsNavGraph.kt
│ │ │ │ ├── JetnewsNavigation.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── article/
│ │ │ │ │ ├── ArticleScreen.kt
│ │ │ │ │ └── PostContent.kt
│ │ │ │ ├── components/
│ │ │ │ │ ├── AppNavRail.kt
│ │ │ │ │ └── JetnewsSnackbarHost.kt
│ │ │ │ ├── home/
│ │ │ │ │ ├── HomeRoute.kt
│ │ │ │ │ ├── HomeScreens.kt
│ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ ├── PostCards.kt
│ │ │ │ │ ├── PostCardTop.kt
│ │ │ │ │ └── PostCardYourNetwork.kt
│ │ │ │ ├── interests/
│ │ │ │ │ ├── InterestsRoute.kt
│ │ │ │ │ ├── InterestsScreen.kt
│ │ │ │ │ ├── InterestsViewModel.kt
│ │ │ │ │ └── SelectTopicButton.kt
│ │ │ │ ├── modifiers/
│ │ │ │ │ └── KeyEvents.kt
│ │ │ │ ├── theme/
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Shape.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ │ └── utils/
│ │ │ │ └── JetnewsIcons.kt
│ │ │ └── utils/
│ │ │ ├── ErrorMessage.kt
│ │ │ ├── LazyListUtils.kt
│ │ │ ├── MapExtensions.kt
│ │ │ └── MultipreviewAnnotations.kt
│ │ └── res/
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── integers.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ ├── xml/
│ │ │ └── jetnews_glance_appwidget_info.xml
│ │ └── xml-v31/
│ │ └── jetnews_glance_appwidget_info.xml
│ └── .run/
│ ├── Instrumented tests.run.xml
│ └── Robolectric tests.run.xml
├── Jetsnack/
│ ├── README.md
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ └── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── androidTest/
│ │ └── java/
│ │ └── com/
│ │ └── example/
│ │ └── jetsnack/
│ │ └── AppTest.kt
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ └── jetsnack/
│ │ ├── model/
│ │ │ ├── Filter.kt
│ │ │ ├── Search.kt
│ │ │ ├── Snack.kt
│ │ │ ├── SnackbarManager.kt
│ │ │ └── SnackCollection.kt
│ │ └── ui/
│ │ ├── JetsnackApp.kt
│ │ ├── MainActivity.kt
│ │ ├── SnackSharedElementKey.kt
│ │ ├── components/
│ │ │ ├── Button.kt
│ │ │ ├── Card.kt
│ │ │ ├── Divider.kt
│ │ │ ├── Filters.kt
│ │ │ ├── Gradient.kt
│ │ │ ├── GradientTintedIconButton.kt
│ │ │ ├── Grid.kt
│ │ │ ├── QuantitySelector.kt
│ │ │ ├── Scaffold.kt
│ │ │ ├── Snackbar.kt
│ │ │ ├── Snacks.kt
│ │ │ └── Surface.kt
│ │ ├── home/
│ │ │ ├── DestinationBar.kt
│ │ │ ├── Feed.kt
│ │ │ ├── FilterScreen.kt
│ │ │ ├── Home.kt
│ │ │ ├── Profile.kt
│ │ │ ├── cart/
│ │ │ │ ├── Cart.kt
│ │ │ │ ├── CartViewModel.kt
│ │ │ │ └── SwipeDismissItem.kt
│ │ │ └── search/
│ │ │ ├── Categories.kt
│ │ │ ├── Results.kt
│ │ │ ├── Search.kt
│ │ │ └── Suggestions.kt
│ │ ├── navigation/
│ │ │ └── JetsnackNavController.kt
│ │ ├── snackdetail/
│ │ │ └── SnackDetail.kt
│ │ ├── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Shape.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ └── utils/
│ │ └── Currency.kt
│ └── res/
│ ├── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── values-night/
│ └── themes.xml
└── Reply/
├── README.md
├── build.gradle.kts
├── settings.gradle.kts
└── app/
├── build.gradle.kts
└── src/
└── main/
├── AndroidManifest.xml
├── java/
│ └── com/
│ └── example/
│ └── reply/
│ ├── data/
│ │ ├── Account.kt
│ │ ├── AccountsRepository.kt
│ │ ├── AccountsRepositoryImpl.kt
│ │ ├── Email.kt
│ │ ├── EmailAttachment.kt
│ │ ├── EmailsRepository.kt
│ │ ├── EmailsRepositoryImpl.kt
│ │ ├── MailboxType.kt
│ │ └── local/
│ │ ├── LocalAccountsDataProvider.kt
│ │ └── LocalEmailsDataProvider.kt
│ └── ui/
│ ├── EmptyComingSoon.kt
│ ├── MainActivity.kt
│ ├── ReplyApp.kt
│ ├── ReplyHomeViewModel.kt
│ ├── ReplyListContent.kt
│ ├── components/
│ │ ├── ReplyAppBars.kt
│ │ ├── ReplyEmailListItem.kt
│ │ ├── ReplyEmailThreadItem.kt
│ │ └── ReplyProfileImage.kt
│ ├── navigation/
│ │ ├── ReplyNavigationActions.kt
│ │ └── ReplyNavigationComponents.kt
│ ├── theme/
│ │ ├── Color.kt
│ │ ├── Shapes.kt
│ │ ├── Theme.kt
│ │ └── Type.kt
│ └── utils/
│ └── WindowStateUtils.kt
└── res/
└── values/
├── strings.xml
└── themes.xml
================================================
FILE: README.md
================================================
# Jetpack Compose Samples
<img src="readme/samples_montage.gif" alt="Jetpack Compose Samples" width="824" />
This repository contains a set of individual Android Studio projects to help you learn about
Compose in Android. Each sample demonstrates different use cases, complexity levels and APIs.
For more information, please [read the documentation](https://developer.android.com/jetpack/compose).
💻 Requirements
------------
To try out these sample apps, you need to use [Android Studio](https://developer.android.com/studio).
You can clone this repository or import the
project from Android Studio following the steps
[here](https://developer.android.com/jetpack/compose/setup#sample).
🧬 Samples
------------
| Project | |
|:-----|---------|
| <br><img src="readme/jetnews.png" alt="JetNews" width="240"></img> <br><br> A sample blog post viewer that demonstrates the use of Compose with a typical Material app and real-world architecture. <br><br> • Medium complexity<br>• Varied UI<br>• Light & dark themes<br>• Resource loading<br>• UI Testing <br><br> **[> Browse](JetNews/)**<br><br> | <img src="readme/screenshots/JetNews.png" width="320" alt="Jetnews sample demo"> |
| | |
| <br><img src="readme/jetchat.png" alt="Jetchat" width="240"></img> <br><br>A sample chat app that focuses on UI state patterns and text input.<br><br>• Low complexity<br>• Material Design 3 theme and Material You dynamic color<br>• Resource loading<br>• Back button handling<br>• Integration with Architecture Components: Navigation, Fragments, LiveData, ViewModel<br>• Animation<br>• UI Testing<br><br>**[> Browse](Jetchat/)** <br><br> | <img src="readme/screenshots/Jetchat.png" width="320" alt="Jetchat sample demo">|
| | |
| <br><img src="readme/jetsnack.png" alt="Jetsnack" width="240"></img> <br><br>Jetsnack is a sample snack ordering app built with Compose.<br><br>• Medium complexity<br>• Custom design system<br>• Custom layouts<br>• Animation<br><br>**[> Browse](Jetsnack/)** <br><br> | <img src="readme/screenshots/Jetsnack.png" width="320" alt="Jetsnack sample demo">|
| | |
| <br><img src="readme/jetcaster.png" alt="Jetcaster" width="240"></img> <br><br>A sample podcast app that features a full-featured, Redux-style architecture and showcases dynamic themes.<br><br>• Advanced sample<br>• Dynamic theming using podcast artwork<br>• Image fetching<br>• [`WindowInsets`](https://developer.android.com/reference/kotlin/android/view/WindowInsets) support<br>• Coroutines<br>• Local storage with Room<br><br>**[> Browse](Jetcaster/)** <br><br> | <img src="readme/screenshots/Jetcaster.png" width="320" alt="Jetcaster sample demo">|
| | |
| <br><img src="readme/reply.png" alt="Reply" width="240"></img> <br><br>A compose implementation of the Reply material study, an email client app that focuses on adaptive design for mobile, tablets and foldables. It also showcases brand new Material design 3 theming, dynamic colors and navigation components.<br><br>• Medium complexity<br>• Adaptive UI for phones, tablet and desktops<br>• Foldable support<br>• Material 3 theming & Components<br>• Dynamic colors and Light/Dark theme support<br><br>**[> Browse](Reply/)** <br><br> | <img src="readme/screenshots/Reply.png" width="320" alt="Reply sample demo">|
| | |
| <br><img src="readme/jetlagged_heading.png" alt="JetLagged" width="240"></img> <br><br>A sample sleep tracker app, showcasing how to create custom layouts and graphics in Compose<br><br>• Custom Layouts<br>• Graphs with Paths<br><br>**[> Browse](JetLagged/)** <br><br> | <img src="JetLagged/screenshots/JetLagged_Full.png" width="320" alt="JetLagged sample demo">|
🧬 Additional samples
------------
| Project | |
|:-----|---------|
| <br><img src="readme/nia.png" alt="Now in Android" width="240"></img> <br><br>An app for keeping up to date with the latest news and developments in Android.<br><br>• [Jetpack Compose](https://developer.android.com/jetpack/compose) first app.<br>• Implements the recommended Android [Architecture Guidelines](https://developer.android.com/topic/architecture) <br>• Integrates [Jetpack Libraries](https://developer.android.com/jetpack) holistically in the context of a real world app<br><br><a href="https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="70"></a><br>**[> Browse](https://github.com/android/nowinandroid)** <br><br> | <img src="readme/screenshots/NiA.png" width="320" alt="Now In Android Github Repository">|
| | |
| <br><img src="readme/material_catalog.png" alt="Material Catalog" width="240"></img> <br><br>A catalog of Material Design components and features available in Jetpack Compose. See how to implement them and how they look and behave on real devices.<br><br>• Lives in AOSP—always up to date<br>• Uses the same samples as API reference docs<br>• Theme picker to change Material Theming values at runtime<br>• Links to guidelines, docs, source code, and issue tracker<br><br><a href="https://play.google.com/store/apps/details?id=androidx.compose.material.catalog"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="70"></a><br>**[> Browse on AOSP](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/integration-tests/material-catalog)** <br><br> | <img src="readme/screenshots/Material_Catalog.png" width="320" alt="Material Catalog sample demo">|
## High level features
Looking for a sample that has the following features?
### Custom Layouts
* [Jetnews: Interests Screen](https://github.com/android/compose-samples/blob/ee198110d8a7575da281de9bd0f84e91970468ca/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt#L428)
* [Jetchat: AnimatedFabContent](https://github.com/android/compose-samples/blob/ee198110d8a7575da281de9bd0f84e91970468ca/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt#L101)
* [Jetsnack: Grid](https://github.com/android/compose-samples/blob/73d7f25815e6936e0e815ce975905a6f10744c36/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt#L27)
* [Jetsnack: CollapsingImageLayout](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt)
### Theming
* [Jetchat: Material3](https://github.com/android/compose-samples/blob/main/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt#L91)
* [Jetcaster: Custom theme based on cover art](https://github.com/android/compose-samples/blob/main/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt)
* [Jetsnack: Custom Design System](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt)
### Animations
* [Jetsurvey: AnimatedContent](https://github.com/android/compose-samples/pull/842)
* [Jetcaster: Animated theme colors](https://github.com/android/compose-samples/blob/69e9d862b5ffb321064364d7883e859db6daeccd/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt)
* [Jetsnack: Animating Bottom Barl](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt)
### Text
* [Jetchat: Downloadable Fonts](https://github.com/android/compose-samples/pull/787)
### Large Screens
* [Jetcaster - Supporting Pane](https://github.com/android/compose-samples/blob/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt#L282)
* [Jetnews - Window Size Classes](https://github.com/android/compose-samples/blob/69e9d862b5ffb321064364d7883e859db6daeccd/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt#L36)
### TV
* [Jetcaster - TV](https://github.com/android/compose-samples/tree/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/tv-app)
### Wear
* [Jetcaster - Wear](https://github.com/android/compose-samples/tree/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/wear)
## Formatting
To automatically format all samples: Run `./scripts/format.sh`
To check one sample for errors: Navigate to the sample folder and run `./gradlew --init-script buildscripts/init.gradle.kts spotlessCheck`
To format one sample: Navigate to the sample folder and run `./gradlew --init-script buildscripts/init.gradle.kts spotlessApply`
## Updates
To update dependencies to their new stable versions, run:
```
./scripts/updateDeps.sh
```
To make any other manual updates to dependencies (ie add a new dependency or set an alpha version), update the `/scripts/libs.versions.toml` file with changes, and then run `duplicate_version_config.sh` to propogate the changes to all other samples. You can also update the `toml-updater-config.gradle` file with changes that need to propogate to each sample.
## Obsolete Sample Projects
Over time some of our samples become a little stale and are removed to keep the
repository easy to navigate. If you are curious you can still find them in the
history, however if you are new you might be better served sticking to
the most up to date resources.
| Project | Removed | Commit |
| ------------------------------------------------ | -----------|-------------------------------------------------------------------- |
| [Crane](../../../tree/v2024.05.00/Crane) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) |
| [Owl](../../../tree/v2024.05.00/Owl) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) |
| [Jetsurvey](../../../tree/v2024.05.00/Jetsurvey) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) |
| [Rally](../../../tree/v2024.05.00/Rally) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) |
## License
```
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.
```
================================================
FILE: Jetcaster/README.md
================================================
![Jetcaster logo](./docs/logo.png)
# Jetcaster sample 🎙️
Jetcaster is a sample podcast app, built with [Jetpack Compose][compose]. The goal of the sample is to
showcase building with Compose across multiple form factors (mobile, TV, and Wear) and full featured architecture.
To try out this sample app, use the latest stable version
of [Android Studio](https://developer.android.com/studio).
You can clone this repository or import the
project from Android Studio following the steps
[here](https://developer.android.com/jetpack/compose/setup#sample).
## Screenshots
<img src="../readme/jetcaster-hero.png"></img>
## Phone app
### Features
This sample has 3 components: the home screen, the podcast details screen, and the player screen
The home screen is split into sub-screens for easy re-use:
- __Home__, allowing the user to see their subscribed podcasts (top carousel), and navigate between 'Your Library' and 'Discover'
- __Discover__, allowing the user to browse podcast categories
- __Podcast Category__, allowing the user to see a list of recent episodes for podcasts in a given category.
Multiple panes will also be shown depending on the device's [window size class][wsc].
The player screen displays media controls and the currently "playing" podcast (the sample currently **does not** actually play any media—the behavior is simply mocked).
The player screen layout is adapting to different form factors, including a tabletop layout on foldable devices:
![readme_fold](https://github.com/android/compose-samples/assets/10263978/fe02248f-81ce-489b-a6d6-838438c8368e)
### Others
Some other notable things which are implemented:
* Images are all provided from each podcast's RSS feed, and loaded using [Coil][coil] library.
### Architecture
The app is built in a Redux-style, where each UI 'screen' has its own [ViewModel][viewmodel], which exposes a single [StateFlow][stateflow] containing the entire view state. Each [ViewModel][viewmodel] is responsible for subscribing to any data streams required for the view, as well as exposing functions which allow the UI to send events.
Using the example of the home screen in the [`com.example.jetcaster.ui.home`](mobile/src/main/java/com/example/jetcaster/ui/home) package:
- The ViewModel is implemented as [`HomeViewModel`][homevm], which exposes a `StateFlow<HomeViewState>` for the UI to observe.
- [`HomeViewState`][homevm] contains the complete view state for the home screen as an [`@Immutable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Immutable) `data class`.
- The Home Compose UI in [`Home.kt`][homeui] uses [`HomeViewModel`][homevm], and observes it's [`HomeViewState`][homevm] as Compose [State](https://developer.android.com/reference/kotlin/androidx/compose/runtime/State), using [`collectAsStateWithLifecycle()`](https://developer.android.com/reference/kotlin/androidx/lifecycle/compose/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsStateWithLifecycle(androidx.lifecycle.LifecycleOwner,androidx.lifecycle.Lifecycle.State,kotlin.coroutines.CoroutineContext)):
``` kotlin
val viewModel: HomeViewModel = viewModel()
val viewState by viewModel.state.collectAsStateWithLifecycle()
```
This pattern is used across the different screens:
- __Home:__ [`com.example.jetcaster.ui.home`](mobile/src/main/java/com/example/jetcaster/ui/home)
- __Discover:__ [`com.example.jetcaster.ui.home.discover`](mobile/src/main/java/com/example/jetcaster/ui/home/discover)
- __Podcast Category:__ [`com.example.jetcaster.ui.category`](mobile/src/main/java/com/example/jetcaster/ui/home/category)
## Wear
This sample showcases a 2-screen pager which allows navigation between the Player and the Library.
From the Library, users can access latest episodes from subscribed podcasts, and queue.
From the podcast, users can access episode details and add episodes to the queue.
From the Player screen, users can access a volume screen and a playback speed screen.
The sample implements [Wear UX best practices for media apps][mediappsbestpractices], such as:
- Support rotating side button (RSB) and Bezel for scrollable screens
- 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
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
a VolumeScreen that can be reused by media apps to conveniently control volume either by interacting with the rotating side button(RSB)/Bezel or by
using the provided buttons.
For simplicity, this sample uses a mock Player which is reused across form factors,
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]
recommends downloading content onto 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.
### Architecture
The architecture of the Wear app is similar to the phone app architecture: each UI 'screen' has its
own [ViewModel][viewmodel] which exposes a `StateFlow<ScreenState>` for the UI to observe.
## Data
### Podcast data
The podcast data in this sample is dynamically fetched from a number of podcast RSS feeds, which are listed in [`Feeds.kt`](core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt).
The [`PodcastRepository`][podcastrepo] class is responsible for handling the data fetching of all podcast information:
- Each podcast is fetched using [OkHttp][okhttp], and then parsed using [Rome][rome], within [`PodcastFetcher`][fetcher].
- The parsed entities are then added to the local data stores: [`PodcastStore`][podcaststore], [`EpisodeStore`][epstore] & [`CategoryStore`][catstore] for storage in the local [Room][room] [`JetcasterDatabase`][db] database.
### Follow podcasts
The sample allows users to 'follow' podcasts, which is implemented within the data layer in the [`PodcastFollowedEntry`](core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt) entity class, and as functions in [PodcastStore][podcaststore]: `followPodcast()`, `unfollowPodcast()`.
### Date + time
The sample uses the JDK 8 [date and time APIs](https://developer.android.com/reference/java/time/package-summary) through the [desugaring support][jdk8desugar] available in Android Gradle Plugin 4.0+. Relevant Room [`TypeConverters`](https://developer.android.com/reference/kotlin/androidx/room/TypeConverters) are implemented in [`DateTimeTypeConverters.kt`](core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt).
## License
```
Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
[feeds]: mobile/src/main/java/com/example/jetcaster/data/Feeds.kt
[fetcher]: mobile/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt
[podcastrepo]: mobile/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt
[podcaststore]: mobile/src/main/java/com/example/jetcaster/data/PodcastStore.kt
[epstore]: mobile/src/main/java/com/example/jetcaster/data/EpisodeStore.kt
[catstore]: mobile/src/main/java/com/example/jetcaster/data/CategoryStore.kt
[db]: mobile/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt
[glance]: https://developer.android.com/develop/ui/compose/glance
[homevm]: mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt
[homeui]: mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt
[compose]: https://developer.android.com/jetpack/compose
[palette]: https://developer.android.com/reference/kotlin/androidx/palette/graphics/package-summary
[room]: https://developer.android.com/topic/libraries/architecture/room
[viewmodel]: https://developer.android.com/topic/libraries/architecture/viewmodel
[stateflow]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/
[okhttp]: https://square.github.io/okhttp/
[rome]: https://rometools.github.io/rome/
[jdk8desugar]: https://developer.android.com/studio/write/java8-support#library-desugaring
[coil]: https://coil-kt.github.io/coil/
[wsc]: https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes
[mediatoolkit]: https://google.github.io/horologist/media-toolkit/
[mediatoolkitsample]: https://google.github.io/horologist/media-sample/
[wearmediaguidance]: https://developer.android.com/media/implement/surfaces/wear-os#play-downloaded-content
[horologist]: https://google.github.io/horologist/
[entityscreen]: https://github.com/google/horologist/blob/main/media/ui/src/main/java/com/google/android/horologist/media/ui/screens/entity/EntityScreen.kt
[mediappsbestpractices]: https://developer.android.com/design/ui/wear/guides/foundations/media-apps
================================================
FILE: Jetcaster/build.gradle.kts
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://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.
*/
plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.version.catalog.update)
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.spotless) apply false
}
apply("${project.rootDir}/buildscripts/toml-updater-config.gradle")
subprojects {
apply(plugin = "com.diffplug.spotless")
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
kotlin {
target("**/*.kt")
targetExclude("${layout.buildDirectory}/**/*.kt")
ktlint()
licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
}
kotlinGradle {
target("*.gradle.kts")
targetExclude("${layout.buildDirectory}/**/*.kt")
ktlint()
// Look for the first line that doesn't have a block comment (assumed to be the license)
licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)")
}
}
}
================================================
FILE: Jetcaster/settings.gradle.kts
================================================
/*
* 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.
*/
val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID")
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
snapshotVersion?.let {
println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/")
maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") }
}
google()
mavenCentral()
}
}
rootProject.name = "Jetcaster"
include(
":mobile",
":core:data",
":core:data-testing",
":core:domain",
":core:domain-testing",
":core:designsystem",
":tv",
":wear",
":glancewidget"
)
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
================================================
FILE: Jetcaster/core/data/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.example.jetcaster.core.data"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.compose.runtime)
// Image loading
implementation(libs.coil.kt.compose)
// Compose
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
// Dependency injection
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Networking
implementation(libs.okhttp3)
implementation(libs.okhttp.logging)
// Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.rometools.rome)
implementation(libs.rometools.modules)
coreLibraryDesugaring(libs.core.jdk.desugaring)
// Testing
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
================================================
FILE: Jetcaster/core/data/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt
================================================
/*
* 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.core.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dispatcher(val jetcasterDispatcher: JetcasterDispatchers)
enum class JetcasterDispatchers {
Main,
IO,
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database
import androidx.room.TypeConverter
import java.time.Duration
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
/**
* Room [TypeConverter] functions for various `java.time.*` classes.
*/
object DateTimeTypeConverters {
@TypeConverter
@JvmStatic
fun toOffsetDateTime(value: String?): OffsetDateTime? {
return value?.let { OffsetDateTime.parse(it) }
}
@TypeConverter
@JvmStatic
fun fromOffsetDateTime(date: OffsetDateTime?): String? {
return date?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
@TypeConverter
@JvmStatic
fun toLocalDateTime(value: String?): LocalDateTime? {
return value?.let { LocalDateTime.parse(value) }
}
@TypeConverter
@JvmStatic
fun fromLocalDateTime(value: LocalDateTime?): String? {
return value?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
}
@TypeConverter
@JvmStatic
fun toDuration(value: Long?): Duration? {
return value?.let { Duration.ofMillis(it) }
}
@TypeConverter
@JvmStatic
fun fromDuration(value: Duration?): Long? {
return value?.toMillis()
}
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.jetcaster.core.data.database.dao.CategoriesDao
import com.example.jetcaster.core.data.database.dao.EpisodesDao
import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastsDao
import com.example.jetcaster.core.data.database.dao.TransactionRunnerDao
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry
import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry
/**
* The [RoomDatabase] we use in this app.
*/
@Database(
entities = [
Podcast::class,
Episode::class,
PodcastCategoryEntry::class,
Category::class,
PodcastFollowedEntry::class,
],
version = 1,
exportSchema = false,
)
@TypeConverters(DateTimeTypeConverters::class)
abstract class JetcasterDatabase : RoomDatabase() {
abstract fun podcastsDao(): PodcastsDao
abstract fun episodesDao(): EpisodesDao
abstract fun categoriesDao(): CategoriesDao
abstract fun podcastCategoryEntryDao(): PodcastCategoryEntryDao
abstract fun transactionRunnerDao(): TransactionRunnerDao
abstract fun podcastFollowedEntryDao(): PodcastFollowedEntryDao
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt
================================================
/*
* 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.core.data.database.dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Update
/**
* Base DAO.
*/
interface BaseDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: T): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(vararg entity: T)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entities: Collection<T>)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(entity: T)
@Delete
suspend fun delete(entity: T): Int
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.example.jetcaster.core.data.database.model.Category
import kotlinx.coroutines.flow.Flow
/**
* [Room] DAO for [Category] related operations.
*/
@Dao
abstract class CategoriesDao : BaseDao<Category> {
@Query(
"""
SELECT categories.* FROM categories
INNER JOIN (
SELECT category_id, COUNT(podcast_uri) AS podcast_count FROM podcast_category_entries
GROUP BY category_id
) ON category_id = categories.id
ORDER BY podcast_count DESC
LIMIT :limit
""",
)
abstract fun categoriesSortedByPodcastCount(limit: Int): Flow<List<Category>>
@Query("SELECT * FROM categories WHERE name = :name")
abstract suspend fun getCategoryWithName(name: String): Category?
@Query("SELECT * FROM categories WHERE name = :name")
abstract fun observeCategory(name: String): Flow<Category?>
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import kotlinx.coroutines.flow.Flow
/**
* [Room] DAO for [Episode] related operations.
*/
@Dao
abstract class EpisodesDao : BaseDao<Episode> {
@Query(
"""
SELECT * FROM episodes WHERE uri = :uri
""",
)
abstract fun episode(uri: String): Flow<Episode>
@Transaction
@Query(
"""
SELECT episodes.* FROM episodes
INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri
WHERE episodes.uri = :episodeUri
""",
)
abstract fun episodeAndPodcast(episodeUri: String): Flow<EpisodeToPodcast>
@Transaction
@Query(
"""
SELECT * FROM episodes WHERE podcast_uri = :podcastUri
ORDER BY datetime(published) DESC
LIMIT :limit
""",
)
abstract fun episodesForPodcastUri(podcastUri: String, limit: Int): Flow<List<EpisodeToPodcast>>
@Transaction
@Query(
"""
SELECT episodes.* FROM episodes
INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri
WHERE category_id = :categoryId
ORDER BY datetime(published) DESC
LIMIT :limit
""",
)
abstract fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow<List<EpisodeToPodcast>>
@Query("SELECT COUNT(*) FROM episodes")
abstract suspend fun count(): Int
@Transaction
@Query(
"""
SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris)
ORDER BY datetime(published) DESC
LIMIT :limit
""",
)
abstract fun episodesForPodcasts(podcastUris: List<String>, limit: Int): Flow<List<EpisodeToPodcast>>
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry
/**
* [Room] DAO for [PodcastCategoryEntry] related operations.
*/
@Dao
abstract class PodcastCategoryEntryDao : BaseDao<PodcastCategoryEntry>
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry
@Dao
abstract class PodcastFollowedEntryDao : BaseDao<PodcastFollowedEntry> {
@Query("DELETE FROM podcast_followed_entries WHERE podcast_uri = :podcastUri")
abstract suspend fun deleteWithPodcastUri(podcastUri: String)
@Query("SELECT COUNT(*) FROM podcast_followed_entries WHERE podcast_uri = :podcastUri")
protected abstract suspend fun podcastFollowRowCount(podcastUri: String): Int
suspend fun isPodcastFollowed(podcastUri: String): Boolean {
return podcastFollowRowCount(podcastUri) > 0
}
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import kotlinx.coroutines.flow.Flow
/**
* [Room] DAO for [Podcast] related operations.
*/
@Dao
abstract class PodcastsDao : BaseDao<Podcast> {
@Query("SELECT * FROM podcasts WHERE uri = :uri")
abstract fun podcastWithUri(uri: String): Flow<Podcast>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT podcast_uri, MAX(published) AS last_episode_date
FROM episodes
GROUP BY podcast_uri
) episodes ON podcasts.uri = episodes.podcast_uri
LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = podcasts.uri
WHERE podcasts.uri = :podcastUri
ORDER BY datetime(last_episode_date) DESC
""",
)
abstract fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT podcast_uri, MAX(published) AS last_episode_date
FROM episodes
GROUP BY podcast_uri
) episodes ON podcasts.uri = episodes.podcast_uri
LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri
ORDER BY datetime(last_episode_date) DESC
LIMIT :limit
""",
)
abstract fun podcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT episodes.podcast_uri, MAX(published) AS last_episode_date
FROM episodes
INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri
WHERE category_id = :categoryId
GROUP BY episodes.podcast_uri
) inner_query ON podcasts.uri = inner_query.podcast_uri
LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri
ORDER BY datetime(last_episode_date) DESC
LIMIT :limit
""",
)
abstract fun podcastsInCategorySortedByLastEpisode(categoryId: Long, limit: Int): Flow<List<PodcastWithExtraInfo>>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri
) episodes ON podcasts.uri = episodes.podcast_uri
INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri
ORDER BY datetime(last_episode_date) DESC
LIMIT :limit
""",
)
abstract fun followedPodcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri
) episodes ON podcasts.uri = episodes.podcast_uri
INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri
WHERE podcasts.title LIKE '%' || :keyword || '%'
ORDER BY datetime(last_episode_date) DESC
LIMIT :limit
""",
)
abstract fun searchPodcastByTitle(keyword: String, limit: Int): Flow<List<PodcastWithExtraInfo>>
@Transaction
@Query(
"""
SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed
FROM podcasts
INNER JOIN (
SELECT episodes.podcast_uri, MAX(published) AS last_episode_date
FROM episodes
INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri
WHERE category_id IN (:categoryIdList)
GROUP BY episodes.podcast_uri
) inner_query ON podcasts.uri = inner_query.podcast_uri
LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri
WHERE podcasts.title LIKE '%' || :keyword || '%'
ORDER BY datetime(last_episode_date) DESC
LIMIT :limit
""",
)
abstract fun searchPodcastByTitleAndCategory(keyword: String, categoryIdList: List<Long>, limit: Int): Flow<List<PodcastWithExtraInfo>>
@Query("SELECT COUNT(*) FROM podcasts")
abstract suspend fun count(): Int
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.dao
import androidx.room.Dao
import androidx.room.Ignore
import androidx.room.Transaction
/**
* [Room] DAO which provides the implementation for our [TransactionRunner].
*/
@Dao
abstract class TransactionRunnerDao : TransactionRunner {
@Transaction
protected open suspend fun runInTransaction(tx: suspend () -> Unit) = tx()
@Ignore
override suspend fun invoke(tx: suspend () -> Unit) {
runInTransaction(tx)
}
}
/**
* Interface with operator function which will invoke the suspending lambda within a database
* transaction.
*/
interface TransactionRunner {
suspend operator fun invoke(tx: suspend () -> Unit)
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "categories",
indices = [
Index("name", unique = true),
],
)
@Immutable
data class Category(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
)
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import java.time.Duration
import java.time.OffsetDateTime
@Entity(
tableName = "episodes",
indices = [
Index("uri", unique = true),
Index("podcast_uri"),
],
foreignKeys = [
ForeignKey(
entity = Podcast::class,
parentColumns = ["uri"],
childColumns = ["podcast_uri"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE,
),
],
)
@TypeConverters(ListOfStringConverter::class)
data class Episode(
@PrimaryKey @ColumnInfo(name = "uri") val uri: String,
@ColumnInfo(name = "podcast_uri") val podcastUri: String,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "subtitle") val subtitle: String? = null,
@ColumnInfo(name = "summary") val summary: String? = null,
@ColumnInfo(name = "author") val author: String? = null,
@ColumnInfo(name = "published") val published: OffsetDateTime,
@ColumnInfo(name = "duration") val duration: Duration? = null,
@ColumnInfo(name = "media_urls") val mediaUrls: List<String>,
)
class ListOfStringConverter {
@TypeConverter
fun fromString(value: String): List<String> {
return value.split(",")
}
@TypeConverter
fun fromList(list: List<String>): String {
return list.joinToString(",")
}
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.room.Embedded
import androidx.room.Ignore
import androidx.room.Relation
import java.util.Objects
class EpisodeToPodcast {
@Embedded
lateinit var episode: Episode
@Relation(parentColumn = "podcast_uri", entityColumn = "uri")
lateinit var _podcasts: List<Podcast>
@get:Ignore
val podcast: Podcast
get() = _podcasts[0]
/**
* Allow consumers to destructure this class
*/
operator fun component1() = episode
operator fun component2() = podcast
override fun equals(other: Any?): Boolean = when {
other === this -> true
other is EpisodeToPodcast -> episode == other.episode && _podcasts == other._podcasts
else -> false
}
override fun hashCode(): Int = Objects.hash(episode, _podcasts)
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "podcasts",
indices = [
Index("uri", unique = true),
],
)
@Immutable
data class Podcast(
@PrimaryKey @ColumnInfo(name = "uri") val uri: String,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "description") val description: String? = null,
@ColumnInfo(name = "author") val author: String? = null,
@ColumnInfo(name = "image_url") val imageUrl: String? = null,
@ColumnInfo(name = "copyright") val copyright: String? = null,
)
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "podcast_category_entries",
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["category_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Podcast::class,
parentColumns = ["uri"],
childColumns = ["podcast_uri"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("podcast_uri", "category_id", unique = true),
Index("category_id"),
Index("podcast_uri"),
],
)
@Immutable
data class PodcastCategoryEntry(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "podcast_uri") val podcastUri: String,
@ColumnInfo(name = "category_id") val categoryId: Long,
)
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "podcast_followed_entries",
foreignKeys = [
ForeignKey(
entity = Podcast::class,
parentColumns = ["uri"],
childColumns = ["podcast_uri"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("podcast_uri", unique = true),
],
)
@Immutable
data class PodcastFollowedEntry(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "podcast_uri") val podcastUri: String,
)
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.database.model
import androidx.room.ColumnInfo
import androidx.room.Embedded
import java.time.OffsetDateTime
import java.util.Objects
class PodcastWithExtraInfo {
@Embedded
lateinit var podcast: Podcast
@ColumnInfo(name = "last_episode_date")
var lastEpisodeDate: OffsetDateTime? = null
@ColumnInfo(name = "is_followed")
var isFollowed: Boolean = false
/**
* Allow consumers to destructure this class
*/
operator fun component1() = podcast
operator fun component2() = lastEpisodeDate
operator fun component3() = isFollowed
override fun equals(other: Any?): Boolean = when {
other === this -> true
other is PodcastWithExtraInfo -> {
podcast == other.podcast &&
lastEpisodeDate == other.lastEpisodeDate &&
isFollowed == other.isFollowed
}
else -> false
}
override fun hashCode(): Int = Objects.hash(podcast, lastEpisodeDate, isFollowed)
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt
================================================
/*
* 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.core.data.di
import android.content.Context
import androidx.room.Room
import coil.ImageLoader
import com.example.jetcaster.core.data.BuildConfig
import com.example.jetcaster.core.data.Dispatcher
import com.example.jetcaster.core.data.JetcasterDispatchers
import com.example.jetcaster.core.data.database.JetcasterDatabase
import com.example.jetcaster.core.data.database.dao.CategoriesDao
import com.example.jetcaster.core.data.database.dao.EpisodesDao
import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastsDao
import com.example.jetcaster.core.data.database.dao.TransactionRunner
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.LocalCategoryStore
import com.example.jetcaster.core.data.repository.LocalEpisodeStore
import com.example.jetcaster.core.data.repository.LocalPodcastStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.rometools.rome.io.SyndFeedInput
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.LoggingEventListener
@Module
@InstallIn(SingletonComponent::class)
object DataDiModule {
@Provides
@Singleton
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
.cache(Cache(File(context.cacheDir, "http_cache"), (20 * 1024 * 1024).toLong()))
.apply {
if (BuildConfig.DEBUG) eventListenerFactory(LoggingEventListener.Factory())
}
.build()
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): JetcasterDatabase =
Room.databaseBuilder(context, JetcasterDatabase::class.java, "data.db")
// This is not recommended for normal apps, but the goal of this sample isn't to
// showcase all of Room.
.fallbackToDestructiveMigration()
.build()
@Provides
@Singleton
fun provideImageLoader(@ApplicationContext context: Context): ImageLoader = ImageLoader.Builder(context)
// Disable `Cache-Control` header support as some podcast images disable disk caching.
.respectCacheHeaders(false)
.build()
@Provides
@Singleton
fun provideCategoriesDao(database: JetcasterDatabase): CategoriesDao = database.categoriesDao()
@Provides
@Singleton
fun providePodcastCategoryEntryDao(database: JetcasterDatabase): PodcastCategoryEntryDao = database.podcastCategoryEntryDao()
@Provides
@Singleton
fun providePodcastsDao(database: JetcasterDatabase): PodcastsDao = database.podcastsDao()
@Provides
@Singleton
fun provideEpisodesDao(database: JetcasterDatabase): EpisodesDao = database.episodesDao()
@Provides
@Singleton
fun providePodcastFollowedEntryDao(database: JetcasterDatabase): PodcastFollowedEntryDao = database.podcastFollowedEntryDao()
@Provides
@Singleton
fun provideTransactionRunner(database: JetcasterDatabase): TransactionRunner = database.transactionRunnerDao()
@Provides
@Singleton
fun provideSyndFeedInput() = SyndFeedInput()
@Provides
@Dispatcher(JetcasterDispatchers.IO)
@Singleton
fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(JetcasterDispatchers.Main)
@Singleton
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
@Provides
@Singleton
fun provideEpisodeStore(episodeDao: EpisodesDao): EpisodeStore = LocalEpisodeStore(episodeDao)
@Provides
@Singleton
fun providePodcastStore(
podcastDao: PodcastsDao,
podcastFollowedEntryDao: PodcastFollowedEntryDao,
transactionRunner: TransactionRunner,
): PodcastStore = LocalPodcastStore(
podcastDao = podcastDao,
podcastFollowedEntryDao = podcastFollowedEntryDao,
transactionRunner = transactionRunner,
)
@Provides
@Singleton
fun provideCategoryStore(
categoriesDao: CategoriesDao,
podcastCategoryEntryDao: PodcastCategoryEntryDao,
podcastDao: PodcastsDao,
episodeDao: EpisodesDao,
): CategoryStore = LocalCategoryStore(
episodesDao = episodeDao,
podcastsDao = podcastDao,
categoriesDao = categoriesDao,
categoryEntryDao = podcastCategoryEntryDao,
)
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.network
/**
* A hand selected list of feeds URLs used for the purposes of displaying real information
* in this sample app.
*/
private const val NowInAndroid = "https://feeds.libsyn.com/244409/rss"
private const val AndroidDevelopersBackstage =
"https://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage"
val SampleFeeds = listOf(
NowInAndroid,
AndroidDevelopersBackstage,
"https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/" +
"dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss",
"https://audioboom.com/channels/2399216.rss",
"https://fragmentedpodcast.com/feed/",
"https://feeds.megaphone.fm/replyall",
"https://feeds.thisamericanlife.org/talpodcast",
"https://feeds.npr.org/510289/podcast.xml",
"https://feeds.99percentinvisible.org/99percentinvisible",
"https://www.howstuffworks.com/podcasts/stuff-you-should-know.rss",
"https://www.thenakedscientists.com/naked_scientists_podcast.xml",
"https://rss.art19.com/the-daily",
"https://rss.art19.com/lisk",
"https://omny.fm/shows/silence-is-not-an-option/playlists/podcast.rss",
"https://audioboom.com/channels/5025217.rss",
"https://feeds.simplecast.com/7PvD7RPL",
"https://feeds.buzzsprout.com/1006078.rss",
"https://feeds.megaphone.fm/HSW9992617712",
)
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.network
import java.io.IOException
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import okhttp3.internal.closeQuietly
/**
* Suspending wrapper around an OkHttp [Call], using [Call.enqueue].
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {
// If we have a response but we're cancelled while resuming, we need to
// close() the unused response
if (response.body != null) {
response.closeQuietly()
}
}
}
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
},
)
continuation.invokeOnCancellation {
try {
cancel()
} catch (t: Throwable) {
// Ignore cancel exception
}
}
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.network
import coil.network.HttpException
import com.example.jetcaster.core.data.Dispatcher
import com.example.jetcaster.core.data.JetcasterDispatchers
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.Podcast
import com.rometools.modules.itunes.EntryInformation
import com.rometools.modules.itunes.FeedInformation
import com.rometools.rome.feed.synd.SyndEnclosure
import com.rometools.rome.feed.synd.SyndEntry
import com.rometools.rome.feed.synd.SyndFeed
import com.rometools.rome.io.SyndFeedInput
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* A class which fetches some selected podcast RSS feeds.
*
* @param okHttpClient [OkHttpClient] to use for network requests
* @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds.
* @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests.
*/
class PodcastsFetcher @Inject constructor(
private val okHttpClient: OkHttpClient,
private val syndFeedInput: SyndFeedInput,
@Dispatcher(JetcasterDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) {
/**
* It seems that most podcast hosts do not implement HTTP caching appropriately.
* Instead of fetching data on every app open, we instead allow the use of 'stale'
* network responses (up to 8 hours).
*/
private val cacheControl by lazy {
CacheControl.Builder().maxStale(8, TimeUnit.HOURS).build()
}
/**
* Returns a [Flow] which fetches each podcast feed and emits it in turn.
*
* The feeds are fetched concurrently, meaning that the resulting emission order may not
* match the order of [feedUrls].
*/
operator fun invoke(feedUrls: List<String>): Flow<PodcastRssResponse> {
// We use flatMapMerge here to achieve concurrent fetching/parsing of the feeds.
return feedUrls.asFlow()
.flatMapMerge { feedUrl ->
flow {
emit(fetchPodcast(feedUrl))
}.catch { e ->
// If an exception was caught while fetching the podcast, wrap it in
// an Error instance.
emit(PodcastRssResponse.Error(e))
}
}
}
private suspend fun fetchPodcast(url: String): PodcastRssResponse {
return withContext(ioDispatcher) {
val request = Request.Builder()
.url(url)
.cacheControl(cacheControl)
.build()
val response = okHttpClient.newCall(request).execute()
// If the network request wasn't successful, throw an exception
if (!response.isSuccessful) throw HttpException(response)
// Otherwise we can parse the response using a Rome SyndFeedInput, then map it
// to a Podcast instance. We run this on the IO dispatcher since the parser is reading
// from a stream.
response.body!!.use { body ->
syndFeedInput.build(body.charStream()).toPodcastResponse(url)
}
}
}
}
sealed class PodcastRssResponse {
data class Error(val throwable: Throwable?) : PodcastRssResponse()
data class Success(val podcast: Podcast, val episodes: List<Episode>, val categories: Set<Category>) : PodcastRssResponse()
}
/**
* Map a Rome [SyndFeed] instance to our own [Podcast] data class.
*/
private fun SyndFeed.toPodcastResponse(feedUrl: String): PodcastRssResponse {
val podcastUri = uri ?: feedUrl
val episodes = entries.map { it.toEpisode(podcastUri, it.enclosures) }
val feedInfo = getModule(PodcastModuleDtd) as? FeedInformation
val podcast = Podcast(
uri = podcastUri,
title = title,
description = feedInfo?.summary ?: description,
author = author,
copyright = copyright,
imageUrl = feedInfo?.imageUri?.toString(),
)
val categories = feedInfo?.categories
?.map { Category(name = it.name) }
?.toSet() ?: emptySet()
return PodcastRssResponse.Success(podcast, episodes, categories)
}
/**
* Map a Rome [SyndEntry] instance to our own [Episode] data class.
*/
private fun SyndEntry.toEpisode(podcastUri: String, enclosures: List<SyndEnclosure>): Episode {
val entryInformation = getModule(PodcastModuleDtd) as? EntryInformation
return Episode(
uri = uri,
podcastUri = podcastUri,
title = title,
author = author,
summary = entryInformation?.summary ?: description?.value,
subtitle = entryInformation?.subtitle,
published = Instant.ofEpochMilli(publishedDate.time).atOffset(ZoneOffset.UTC),
duration = entryInformation?.duration?.milliseconds?.let { Duration.ofMillis(it) },
mediaUrls = enclosures.map { it.url },
)
}
/**
* Most feeds use the following DTD to include extra information related to
* their podcast. Info such as images, summaries, duration, categories is sometimes only available
* via this attributes in this DTD.
*/
private const val PodcastModuleDtd = "http://www.itunes.com/dtds/podcast-1.0.dtd"
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.repository
import com.example.jetcaster.core.data.database.dao.CategoriesDao
import com.example.jetcaster.core.data.database.dao.EpisodesDao
import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastsDao
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import kotlinx.coroutines.flow.Flow
interface CategoryStore {
/**
* Returns a flow containing a list of categories which is sorted by the number
* of podcasts in each category.
*/
fun categoriesSortedByPodcastCount(limit: Int = Integer.MAX_VALUE): Flow<List<Category>>
/**
* Returns a flow containing a list of podcasts in the category with the given [categoryId],
* sorted by the their last episode date.
*/
fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int = Int.MAX_VALUE): Flow<List<PodcastWithExtraInfo>>
/**
* Returns a flow containing a list of episodes from podcasts in the category with the
* given [categoryId], sorted by the their last episode date.
*/
fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int = Integer.MAX_VALUE): Flow<List<EpisodeToPodcast>>
/**
* Adds the category to the database if it doesn't already exist.
*
* @return the id of the newly inserted/existing category
*/
suspend fun addCategory(category: Category): Long
suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long)
/**
* @return gets the category with [name], if it exists, otherwise, null
*/
fun getCategory(name: String): Flow<Category?>
}
/**
* A data repository for [Category] instances.
*/
class LocalCategoryStore constructor(
private val categoriesDao: CategoriesDao,
private val categoryEntryDao: PodcastCategoryEntryDao,
private val episodesDao: EpisodesDao,
private val podcastsDao: PodcastsDao,
) : CategoryStore {
/**
* Returns a flow containing a list of categories which is sorted by the number
* of podcasts in each category.
*/
override fun categoriesSortedByPodcastCount(limit: Int): Flow<List<Category>> {
return categoriesDao.categoriesSortedByPodcastCount(limit)
}
/**
* Returns a flow containing a list of podcasts in the category with the given [categoryId],
* sorted by the their last episode date.
*/
override fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int): Flow<List<PodcastWithExtraInfo>> {
return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit)
}
/**
* Returns a flow containing a list of episodes from podcasts in the category with the
* given [categoryId], sorted by the their last episode date.
*/
override fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow<List<EpisodeToPodcast>> {
return episodesDao.episodesFromPodcastsInCategory(categoryId, limit)
}
/**
* Adds the category to the database if it doesn't already exist.
*
* @return the id of the newly inserted/existing category
*/
override suspend fun addCategory(category: Category): Long {
return when (val local = categoriesDao.getCategoryWithName(category.name)) {
null -> categoriesDao.insert(category)
else -> local.id
}
}
override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) {
categoryEntryDao.insert(
PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId),
)
}
override fun getCategory(name: String): Flow<Category?> = categoriesDao.observeCategory(name)
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.repository
import com.example.jetcaster.core.data.database.dao.EpisodesDao
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import kotlinx.coroutines.flow.Flow
interface EpisodeStore {
/**
* Returns a flow containing the episode given [episodeUri].
*/
fun episodeWithUri(episodeUri: String): Flow<Episode>
/**
* Returns a flow containing the episode and corresponding podcast given an [episodeUri].
*/
fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast>
/**
* Returns a flow containing the list of episodes associated with the podcast with the
* given [podcastUri].
*/
fun episodesInPodcast(podcastUri: String, limit: Int = Integer.MAX_VALUE): Flow<List<EpisodeToPodcast>>
/**
* Returns a list of episodes for the given podcast URIs ordering by most recently published
* to least recently published.
*/
fun episodesInPodcasts(podcastUris: List<String>, limit: Int = Integer.MAX_VALUE): Flow<List<EpisodeToPodcast>>
/**
* Add a new [Episode] to this store.
*
* This automatically switches to the main thread to maintain thread consistency.
*/
suspend fun addEpisodes(episodes: Collection<Episode>)
/**
* Deletes an [Episode] from this store.
*/
suspend fun deleteEpisode(episode: Episode)
suspend fun isEmpty(): Boolean
}
/**
* A data repository for [Episode] instances.
*/
class LocalEpisodeStore(private val episodesDao: EpisodesDao) : EpisodeStore {
/**
* Returns a flow containing the episode given [episodeUri].
*/
override fun episodeWithUri(episodeUri: String): Flow<Episode> {
return episodesDao.episode(episodeUri)
}
override fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast> = episodesDao.episodeAndPodcast(episodeUri)
/**
* Returns a flow containing the list of episodes associated with the podcast with the
* given [podcastUri].
*/
override fun episodesInPodcast(podcastUri: String, limit: Int): Flow<List<EpisodeToPodcast>> {
return episodesDao.episodesForPodcastUri(podcastUri, limit)
}
/**
* Returns a list of episodes for the given podcast URIs ordering by most recently published
* to least recently published.
*/
override fun episodesInPodcasts(podcastUris: List<String>, limit: Int): Flow<List<EpisodeToPodcast>> =
episodesDao.episodesForPodcasts(podcastUris, limit)
/**
* Add a new [Episode] to this store.
*
* This automatically switches to the main thread to maintain thread consistency.
*/
override suspend fun addEpisodes(episodes: Collection<Episode>) = episodesDao.insertAll(episodes)
/**
* Deletes an [Episode] from this store.
*/
override suspend fun deleteEpisode(episode: Episode) {
episodesDao.delete(episode)
}
override suspend fun isEmpty(): Boolean = episodesDao.count() == 0
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.repository
import com.example.jetcaster.core.data.Dispatcher
import com.example.jetcaster.core.data.JetcasterDispatchers
import com.example.jetcaster.core.data.database.dao.TransactionRunner
import com.example.jetcaster.core.data.network.PodcastRssResponse
import com.example.jetcaster.core.data.network.PodcastsFetcher
import com.example.jetcaster.core.data.network.SampleFeeds
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* Data repository for Podcasts.
*/
class PodcastsRepository @Inject constructor(
private val podcastsFetcher: PodcastsFetcher,
private val podcastStore: PodcastStore,
private val episodeStore: EpisodeStore,
private val categoryStore: CategoryStore,
private val transactionRunner: TransactionRunner,
@Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher,
) {
private var refreshingJob: Job? = null
private val scope = CoroutineScope(mainDispatcher)
suspend fun updatePodcasts(force: Boolean) {
if (refreshingJob?.isActive == true) {
refreshingJob?.join()
} else if (force || podcastStore.isEmpty()) {
val job = scope.launch {
// Now fetch the podcasts, and add each to each store
podcastsFetcher(SampleFeeds)
.filter { it is PodcastRssResponse.Success }
.map { it as PodcastRssResponse.Success }
.collect { (podcast, episodes, categories) ->
transactionRunner {
podcastStore.addPodcast(podcast)
episodeStore.addEpisodes(episodes)
categories.forEach { category ->
// First insert the category
val categoryId = categoryStore.addCategory(category)
// Now we can add the podcast to the category
categoryStore.addPodcastToCategory(
podcastUri = podcast.uri,
categoryId = categoryId,
)
}
}
}
}
refreshingJob = job
// We need to wait here for the job to finish, otherwise the coroutine completes ~immediatelly
job.join()
}
}
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.data.repository
import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao
import com.example.jetcaster.core.data.database.dao.PodcastsDao
import com.example.jetcaster.core.data.database.dao.TransactionRunner
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import kotlinx.coroutines.flow.Flow
interface PodcastStore {
/**
* Return a flow containing the [Podcast] with the given [uri].
*/
fun podcastWithUri(uri: String): Flow<Podcast>
/**
* Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri].
*/
fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo>
/**
* Returns a flow containing the entire collection of podcasts, sorted by the last episode
* publish date for each podcast.
*/
fun podcastsSortedByLastEpisode(limit: Int = Int.MAX_VALUE): Flow<List<PodcastWithExtraInfo>>
/**
* Returns a flow containing a list of all followed podcasts, sorted by the their last
* episode date.
*/
fun followedPodcastsSortedByLastEpisode(limit: Int = Int.MAX_VALUE): Flow<List<PodcastWithExtraInfo>>
/**
* Returns a flow containing a list of podcasts such that its name partially matches
* with the specified keyword
*/
fun searchPodcastByTitle(keyword: String, limit: Int = Int.MAX_VALUE): Flow<List<PodcastWithExtraInfo>>
/**
* Return a flow containing a list of podcast such that it belongs to the any of categories
* specified with categories parameter and its name partially matches with the specified
* keyword.
*/
fun searchPodcastByTitleAndCategories(
keyword: String,
categories: List<Category>,
limit: Int = Int.MAX_VALUE,
): Flow<List<PodcastWithExtraInfo>>
suspend fun togglePodcastFollowed(podcastUri: String)
suspend fun followPodcast(podcastUri: String)
suspend fun unfollowPodcast(podcastUri: String)
/**
* Add a new [Podcast] to this store.
*
* This automatically switches to the main thread to maintain thread consistency.
*/
suspend fun addPodcast(podcast: Podcast)
suspend fun isEmpty(): Boolean
}
/**
* A data repository for [Podcast] instances.
*/
class LocalPodcastStore constructor(
private val podcastDao: PodcastsDao,
private val podcastFollowedEntryDao: PodcastFollowedEntryDao,
private val transactionRunner: TransactionRunner,
) : PodcastStore {
/**
* Return a flow containing the [Podcast] with the given [uri].
*/
override fun podcastWithUri(uri: String): Flow<Podcast> {
return podcastDao.podcastWithUri(uri)
}
/**
* Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri].
*/
override fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> = podcastDao.podcastWithExtraInfo(podcastUri)
/**
* Returns a flow containing the entire collection of podcasts, sorted by the last episode
* publish date for each podcast.
*/
override fun podcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> {
return podcastDao.podcastsSortedByLastEpisode(limit)
}
/**
* Returns a flow containing a list of all followed podcasts, sorted by the their last
* episode date.
*/
override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> {
return podcastDao.followedPodcastsSortedByLastEpisode(limit)
}
override fun searchPodcastByTitle(keyword: String, limit: Int): Flow<List<PodcastWithExtraInfo>> {
return podcastDao.searchPodcastByTitle(keyword, limit)
}
override fun searchPodcastByTitleAndCategories(
keyword: String,
categories: List<Category>,
limit: Int,
): Flow<List<PodcastWithExtraInfo>> {
val categoryIdList = categories.map { it.id }
return podcastDao.searchPodcastByTitleAndCategory(keyword, categoryIdList, limit)
}
override suspend fun followPodcast(podcastUri: String) {
podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri))
}
override suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner {
if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) {
unfollowPodcast(podcastUri)
} else {
followPodcast(podcastUri)
}
}
override suspend fun unfollowPodcast(podcastUri: String) {
podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri)
}
/**
* Add a new [Podcast] to this store.
*
* This automatically switches to the main thread to maintain thread consistency.
*/
override suspend fun addPodcast(podcast: Podcast) {
podcastDao.insert(podcast)
}
override suspend fun isEmpty(): Boolean = podcastDao.count() == 0
}
================================================
FILE: Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt
================================================
/*
* 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.core.util
import kotlinx.coroutines.flow.Flow
/**
* Combines 3 flows into a single flow by combining their latest values using the provided transform function.
*
* @param flow The first flow.
* @param flow2 The second flow.
* @param flow3 The third flow.
* @param transform The transform function to combine the latest values of the three flows.
* @return A flow that emits the results of the transform function applied to the latest values of the three flows.
*/
fun <T1, T2, T3, T4, T5, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
transform: suspend (T1, T2, T3, T4, T5) -> R,
): Flow<R> = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
)
}
fun <T1, T2, R> combine(flow: Flow<T1>, flow2: Flow<T2>, transform: suspend (T1, T2) -> R): Flow<R> =
kotlinx.coroutines.flow.combine(flow, flow2) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
)
}
/**
* Combines six flows into a single flow by combining their latest values using the provided transform function.
*
* @param flow The first flow.
* @param flow2 The second flow.
* @param flow3 The third flow.
* @param flow4 The fourth flow.
* @param flow5 The fifth flow.
* @param flow6 The sixth flow.
* @param transform The transform function to combine the latest values of the six flows.
* @return A flow that emits the results of the transform function applied to the latest values of the six flows.
*/
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
)
}
/**
* Combines seven flows into a single flow by combining their latest values using the provided transform function.
*
* @param flow The first flow.
* @param flow2 The second flow.
* @param flow3 The third flow.
* @param flow4 The fourth flow.
* @param flow5 The fifth flow.
* @param flow6 The sixth flow.
* @param flow7 The seventh flow.
* @param transform The transform function to combine the latest values of the seven flows.
* @return A flow that emits the results of the transform function applied to the latest values of the seven flows.
*/
fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5,
flow6,
flow7,
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
)
}
================================================
FILE: Jetcaster/core/data-testing/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.example.jetcaster.core.data.testing"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(projects.core.data)
coreLibraryDesugaring(libs.core.jdk.desugaring)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
================================================
FILE: Jetcaster/core/data-testing/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
================================================
FILE: Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt
================================================
/*
* 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.core.data.testing.repository
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.repository.CategoryStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
/**
* A [CategoryStore] used for testing.
*
* // TODO: Move to :testing module upon merging PR #1379
*/
class TestCategoryStore : CategoryStore {
private val categoryFlow = MutableStateFlow<List<Category>>(emptyList())
private val podcastsInCategoryFlow =
MutableStateFlow<Map<Long, List<PodcastWithExtraInfo>>>(emptyMap())
private val episodesFromPodcasts =
MutableStateFlow<Map<Long, List<EpisodeToPodcast>>>(emptyMap())
override fun categoriesSortedByPodcastCount(limit: Int): Flow<List<Category>> = categoryFlow
override fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int): Flow<List<PodcastWithExtraInfo>> =
podcastsInCategoryFlow.map {
it[categoryId]?.take(limit) ?: emptyList()
}
override fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow<List<EpisodeToPodcast>> = episodesFromPodcasts.map {
it[categoryId]?.take(limit) ?: emptyList()
}
override suspend fun addCategory(category: Category): Long = -1
override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) {}
override fun getCategory(name: String): Flow<Category?> = flowOf()
/**
* Test-only API for setting the list of categories backed by this [TestCategoryStore].
*/
fun setCategories(categories: List<Category>) {
categoryFlow.value = categories
}
/**
* Test-only API for setting the list of podcasts in a category backed by this
* [TestCategoryStore].
*/
fun setPodcastsInCategory(categoryId: Long, podcastsInCategory: List<PodcastWithExtraInfo>) {
podcastsInCategoryFlow.update {
it + Pair(categoryId, podcastsInCategory)
}
}
/**
* Test-only API for setting the list of podcasts in a category backed by this
* [TestCategoryStore].
*/
fun setEpisodesFromPodcast(categoryId: Long, podcastsInCategory: List<EpisodeToPodcast>) {
episodesFromPodcasts.update {
it + Pair(categoryId, podcastsInCategory)
}
}
}
================================================
FILE: Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt
================================================
/*
* 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.core.data.testing.repository
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.repository.EpisodeStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
// TODO: Move to :testing module upon merging PR #1379
class TestEpisodeStore : EpisodeStore {
private val episodesFlow = MutableStateFlow<List<Episode>>(listOf())
override fun episodeWithUri(episodeUri: String): Flow<Episode> = episodesFlow.map { episodes ->
episodes.first { it.uri == episodeUri }
}
override fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast> = episodesFlow.map { episodes ->
val e = episodes.first {
it.uri == episodeUri
}
EpisodeToPodcast().apply {
episode = e
_podcasts = emptyList()
}
}
override fun episodesInPodcast(podcastUri: String, limit: Int): Flow<List<EpisodeToPodcast>> = episodesFlow.map { episodes ->
episodes.filter {
it.podcastUri == podcastUri
}.map { e ->
EpisodeToPodcast().apply {
episode = e
}
}
}
override fun episodesInPodcasts(podcastUris: List<String>, limit: Int): Flow<List<EpisodeToPodcast>> = episodesFlow.map { episodes ->
episodes.filter {
podcastUris.contains(it.podcastUri)
}.map { ep ->
EpisodeToPodcast().apply {
episode = ep
}
}
}
override suspend fun addEpisodes(episodes: Collection<Episode>) = episodesFlow.update {
it + episodes
}
override suspend fun deleteEpisode(episode: Episode) = episodesFlow.update {
it - episode
}
override suspend fun isEmpty(): Boolean = episodesFlow.first().isEmpty()
}
================================================
FILE: Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt
================================================
/*
* 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.core.data.testing.repository
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.repository.PodcastStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
// TODO: Move to :testing module upon merging PR #1379
class TestPodcastStore : PodcastStore {
private val podcastFlow = MutableStateFlow<List<Podcast>>(listOf())
private val followedPodcasts = mutableSetOf<String>()
override fun podcastWithUri(uri: String): Flow<Podcast> = podcastFlow.map { podcasts ->
podcasts.first { it.uri == uri }
}
override fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> = podcastFlow.map { podcasts ->
val podcast = podcasts.first { it.uri == podcastUri }
PodcastWithExtraInfo().apply {
this.podcast = podcast
}
}
override fun podcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> = podcastFlow.map { podcasts ->
podcasts.map { p ->
PodcastWithExtraInfo().apply {
podcast = p
isFollowed = followedPodcasts.contains(p.uri)
}
}
}
override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> = podcastFlow.map { podcasts ->
podcasts.filter {
followedPodcasts.contains(it.uri)
}.map { p ->
PodcastWithExtraInfo().apply {
podcast = p
isFollowed = true
}
}
}
override fun searchPodcastByTitle(keyword: String, limit: Int): Flow<List<PodcastWithExtraInfo>> = podcastFlow.map { podcastList ->
podcastList.filter {
it.title.contains(keyword)
}.map { p ->
PodcastWithExtraInfo().apply {
podcast = p
isFollowed = true
}
}
}
override fun searchPodcastByTitleAndCategories(
keyword: String,
categories: List<Category>,
limit: Int,
): Flow<List<PodcastWithExtraInfo>> = podcastFlow.map { podcastList ->
podcastList.filter {
it.title.contains(keyword)
}.map { p ->
PodcastWithExtraInfo().apply {
podcast = p
isFollowed = true
}
}
}
override suspend fun togglePodcastFollowed(podcastUri: String) {
if (podcastUri in followedPodcasts) {
unfollowPodcast(podcastUri)
} else {
followPodcast(podcastUri)
}
}
override suspend fun followPodcast(podcastUri: String) {
followedPodcasts.add(podcastUri)
}
override suspend fun unfollowPodcast(podcastUri: String) {
followedPodcasts.remove(podcastUri)
}
override suspend fun addPodcast(podcast: Podcast) = podcastFlow.update { it + podcast }
override suspend fun isEmpty(): Boolean = podcastFlow.first().isEmpty()
}
================================================
FILE: Jetcaster/core/designsystem/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose)
}
// TODO(chris): Set up convention plugin
android {
namespace = "com.example.jetcaster.core.designsystem"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.text)
implementation(libs.coil.kt.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
}
================================================
FILE: Jetcaster/core/designsystem/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt
================================================
/*
* 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.designsystem.component
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
/**
* A container for text that should be HTML formatted. This container will handle building the
* annotated string from [text], and enable text selection if [text] has any selectable element.
*/
@Composable
fun HtmlTextContainer(text: String, content: @Composable (AnnotatedString) -> Unit) {
val annotatedString = remember(key1 = text) {
AnnotatedString.fromHtml(htmlString = text)
}
SelectionContainer {
content(annotatedString)
}
}
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt
================================================
/*
* 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.designsystem.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
@Composable
fun ImageBackgroundColorScrim(url: String?, color: Color, modifier: Modifier = Modifier) {
ImageBackground(
url = url,
modifier = modifier,
overlay = {
drawRect(color)
},
)
}
@Composable
fun ImageBackgroundRadialGradientScrim(url: String?, colors: List<Color>, modifier: Modifier = Modifier) {
ImageBackground(
url = url,
modifier = modifier,
overlay = {
val brush = Brush.radialGradient(
colors = colors,
center = Offset(0f, size.height),
radius = size.width * 1.5f,
)
drawRect(brush, blendMode = BlendMode.Multiply)
},
)
}
/**
* Displays an image scaled 150% overlaid by [overlay]
*/
@Composable
fun ImageBackground(url: String?, overlay: DrawScope.() -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.fillMaxWidth()
.drawWithCache {
onDrawWithContent {
drawContent()
overlay()
}
},
)
}
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt
================================================
/*
* 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.designsystem.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.example.jetcaster.core.designsystem.R
@Composable
fun PodcastImage(
podcastImageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
// TODO: Remove the nested component modifier when shared elements are applied to entire app
imageModifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
placeholderBrush: Brush = thumbnailPlaceholderDefaultBrush(),
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(MaterialTheme.colorScheme.primary))
return
}
var imagePainterState by remember {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
}
val imageLoader = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(podcastImageUrl)
.crossfade(true)
.build(),
contentScale = contentScale,
onState = { state -> imagePainterState = state },
)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
when (imagePainterState) {
is AsyncImagePainter.State.Loading,
is AsyncImagePainter.State.Error,
-> {
Image(
painter = painterResource(id = R.drawable.img_empty),
contentDescription = null,
modifier = Modifier
.fillMaxSize(),
)
}
else -> {
Box(
modifier = modifier
.background(placeholderBrush)
.fillMaxSize(),
)
}
}
Image(
painter = imageLoader,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier.then(imageModifier),
)
}
}
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt
================================================
/*
* 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.designsystem.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import com.example.jetcaster.designsystem.theme.surfaceVariantDark
import com.example.jetcaster.designsystem.theme.surfaceVariantLight
@Composable
internal fun thumbnailPlaceholderDefaultBrush(color: Color = thumbnailPlaceHolderDefaultColor()): Brush {
return SolidColor(color)
}
@Composable
private fun thumbnailPlaceHolderDefaultColor(isInDarkMode: Boolean = isSystemInDarkTheme()): Color {
return if (isInDarkMode) {
surfaceVariantDark
} else {
surfaceVariantLight
}
}
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt
================================================
/*
* 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.designsystem.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFFFF792C)
val onPrimaryLight = Color(0xFF626004)
val primaryContainerLight = Color(0xFF313002)
val onPrimaryContainerLight = Color(0xFFFDFCCE)
val secondaryLight = Color(0xFFFFE523)
val onSecondaryLight = Color(0xFF332D00)
val secondaryContainerLight = Color(0xFF998700)
val onSecondaryContainerLight = Color(0xFFFFF9CC)
val tertiaryLight = Color(0xFFFF9AD8)
val onTertiaryLight = Color(0xFF33000A)
val tertiaryContainerLight = Color(0xFF660014)
val onTertiaryContainerLight = Color(0xFF663600)
val errorLight = Color(0xFFFFE5EB)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFFEF7FF)
val onBackgroundLight = Color(0xFF1D1B20)
val surfaceLight = Color(0xFFFEF7FF)
val onSurfaceLight = Color(0xFF1D1B20)
val surfaceVariantLight = Color(0xFFE7E0EB)
val onSurfaceVariantLight = Color(0xFF49454E)
val outlineLight = Color(0xFF7A757F)
val outlineVariantLight = Color(0xFFCBC4CF)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFFEFE0D6)
val inverseOnSurfaceLight = Color(0xFF382F28)
val inversePrimaryLight = Color(0xFFD3BCFD)
val surfaceDimLight = Color(0xFF19120C)
val surfaceBrightLight = Color(0xFF413731)
val surfaceContainerLowestLight = Color(0xFF140D08)
val surfaceContainerLowLight = Color(0xFF221A14)
val surfaceContainerLight = Color(0xFF261E18)
val surfaceContainerHighLight = Color(0xFF312822)
val surfaceContainerHighestLight = Color(0xFF3C332C)
val primaryLightMediumContrast = Color(0xFFFF792C)
val onPrimaryLightMediumContrast = Color(0xFF626004)
val primaryContainerLightMediumContrast = Color(0xFF313002)
val onPrimaryContainerLightMediumContrast = Color(0xFFFDFCCE)
val secondaryLightMediumContrast = Color(0xFFFFE523)
val onSecondaryLightMediumContrast = Color(0xFF332D00)
val secondaryContainerLightMediumContrast = Color(0xFF998700)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFF9CC)
val tertiaryLightMediumContrast = Color(0xFFFF9AD8)
val onTertiaryLightMediumContrast = Color(0xFF33000A)
val tertiaryContainerLightMediumContrast = Color(0xFF660014)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFE5EB)
val errorLightMediumContrast = Color(0xFF740006)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFFEF7FF)
val onBackgroundLightMediumContrast = Color(0xFF1D1B20)
val surfaceLightMediumContrast = Color(0xFFFEF7FF)
val onSurfaceLightMediumContrast = Color(0xFF121016)
val surfaceVariantLightMediumContrast = Color(0xFFE7E0EB)
val onSurfaceVariantLightMediumContrast = Color(0xFF38353D)
val outlineLightMediumContrast = Color(0xFF55515A)
val outlineVariantLightMediumContrast = Color(0xFF706B75)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF322F35)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF5EFF7)
val inversePrimaryLightMediumContrast = Color(0xFFD3BCFD)
val surfaceDimLightMediumContrast = Color(0xFF19120C)
val surfaceBrightLightMediumContrast = Color(0xFF413731)
val surfaceContainerLowestLightMediumContrast = Color(0xFF140D08)
val surfaceContainerLowLightMediumContrast = Color(0xFF221A14)
val surfaceContainerLightMediumContrast = Color(0xFF261E18)
val surfaceContainerHighLightMediumContrast = Color(0xFF312822)
val surfaceContainerHighestLightMediumContrast = Color(0xFF3C332C)
val primaryLightHighContrast = Color(0xFF342157)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF523F77)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF30293C)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF4D465A)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF45212C)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF673D48)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF600004)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF98000A)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFFEF7FF)
val onBackgroundLightHighContrast = Color(0xFF1D1B20)
val surfaceLightHighContrast = Color(0xFFFEF7FF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE7E0EB)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF2E2B33)
val outlineVariantLightHighContrast = Color(0xFF4C4751)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF322F35)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFD3BCFD)
val surfaceDimLightHighContrast = Color(0xFFBCB7BF)
val surfaceBrightLightHighContrast = Color(0xFFFEF7FF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF5EFF7)
val surfaceContainerLightHighContrast = Color(0xFFE7E0E8)
val surfaceContainerHighLightHighContrast = Color(0xFFD8D2DA)
val surfaceContainerHighestLightHighContrast = Color(0xFFCAC4CC)
val primaryDark = Color(0xFFF0FCB0)
val onPrimaryDark = Color(0xFF626004)
val primaryContainerDark = Color(0xFF313002)
val onPrimaryContainerDark = Color(0xFFFDFCCE)
val secondaryDark = Color(0xFFFFE523)
val onSecondaryDark = Color(0xFF332D00)
val secondaryContainerDark = Color(0xFF998700)
val onSecondaryContainerDark = Color(0xFFFFF9CC)
val tertiaryDark = Color(0xFFFF9AD8)
val onTertiaryDark = Color(0xFF33000A)
val tertiaryContainerDark = Color(0xFF660014)
val onTertiaryContainerDark = Color(0xFFFFE5EB)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF151218)
val onBackgroundDark = Color(0xFFE7E0E8)
val surfaceDark = Color(0xFF261604)
val onSurfaceDark = Color(0xFFFBEDE4)
val surfaceVariantDark = Color(0xFF49454E)
val onSurfaceVariantDark = Color(0xFFCBC4CF)
val outlineDark = Color(0xFF948F99)
val outlineVariantDark = Color(0xFF49454E)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE7E0E8)
val inverseOnSurfaceDark = Color(0xFF322F35)
val inversePrimaryDark = Color(0xFF68548E)
val surfaceDimDark = Color(0xFF19120C)
val surfaceBrightDark = Color(0xFF413731)
val surfaceContainerLowestDark = Color(0xFF140D08)
val surfaceContainerLowDark = Color(0xFF221A14)
val surfaceContainerDark = Color(0xFF261E18)
val surfaceContainerHighDark = Color(0xFF312822)
val surfaceContainerHighestDark = Color(0xFF3C332C)
val primaryDarkMediumContrast = Color(0xFFF0FCB0)
val onPrimaryDarkMediumContrast = Color(0xFF626004)
val primaryContainerDarkMediumContrast = Color(0xFF313002)
val onPrimaryContainerDarkMediumContrast = Color(0xFFFDFCCE)
val secondaryDarkMediumContrast = Color(0xFFFFE523)
val onSecondaryDarkMediumContrast = Color(0xFF332D00)
val secondaryContainerDarkMediumContrast = Color(0xFF998700)
val onSecondaryContainerDarkMediumContrast = Color(0xFFFFF9CC)
val tertiaryDarkMediumContrast = Color(0xFFFF9AD8)
val onTertiaryDarkMediumContrast = Color(0xFF33000A)
val tertiaryContainerDarkMediumContrast = Color(0xFF660014)
val onTertiaryContainerDarkMediumContrast = Color(0xFFFFE5EB)
val errorDarkMediumContrast = Color(0xFFFFD2CC)
val onErrorDarkMediumContrast = Color(0xFF540003)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF151218)
val onBackgroundDarkMediumContrast = Color(0xFFE7E0E8)
val surfaceDarkMediumContrast = Color(0xFF151218)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF49454E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFE1DAE5)
val outlineDarkMediumContrast = Color(0xFFB6B0BA)
val outlineVariantDarkMediumContrast = Color(0xFF948E98)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE7E0E8)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF322F35)
val inversePrimaryDarkMediumContrast = Color(0xFF68548E)
val surfaceDimDarkMediumContrast = Color(0xFF19120C)
val surfaceBrightDarkMediumContrast = Color(0xFF413731)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF140D08)
val surfaceContainerLowDarkMediumContrast = Color(0xFF221A14)
val surfaceContainerDarkMediumContrast = Color(0xFF261E18)
val surfaceContainerHighDarkMediumContrast = Color(0xFF312822)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C332C)
val primaryDarkHighContrast = Color(0xFFF0FCB0)
val onPrimaryDarkHighContrast = Color(0xFF626004)
val primaryContainerDarkHighContrast = Color(0xFF313002)
val onPrimaryContainerDarkHighContrast = Color(0xFFFDFCCE)
val secondaryDarkHighContrast = Color(0xFFFFE523)
val onSecondaryDarkHighContrast = Color(0xFF332D00)
val secondaryContainerDarkHighContrast = Color(0xFF998700)
val onSecondaryContainerDarkHighContrast = Color(0xFFFFF9CC)
val tertiaryDarkHighContrast = Color(0xFFFF9AD8)
val onTertiaryDarkHighContrast = Color(0xFF33000A)
val tertiaryContainerDarkHighContrast = Color(0xFF660014)
val onTertiaryContainerDarkHighContrast = Color(0xFFFFE5EB)
val errorDarkHighContrast = Color(0xFFFFECE9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF151218)
val onBackgroundDarkHighContrast = Color(0xFFE7E0E8)
val surfaceDarkHighContrast = Color(0xFF151218)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF49454E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFF5EDF9)
val outlineVariantDarkHighContrast = Color(0xFFC7C0CB)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE7E0E8)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF513E75)
val surfaceDimDarkHighContrast = Color(0xFF19120C)
val surfaceBrightDarkHighContrast = Color(0xFF413731)
val surfaceContainerLowestDarkHighContrast = Color(0xFF140D08)
val surfaceContainerLowDarkHighContrast = Color(0xFF221A14)
val surfaceContainerDarkHighContrast = Color(0xFF261E18)
val surfaceContainerHighDarkHighContrast = Color(0xFF312822)
val surfaceContainerHighestDarkHighContrast = Color(0xFF3C332C)
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.designsystem.theme
import androidx.compose.ui.unit.dp
val Keyline1 = 16.dp
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.designsystem.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val JetcasterShapes = Shapes(
small = RoundedCornerShape(percent = 50),
medium = RoundedCornerShape(size = 8.dp),
large = RoundedCornerShape(size = 16.dp),
)
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.designsystem.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
val JetcasterTypography = androidx.compose.material3.Typography(
displayLarge = TextStyle(
fontSize = 64.sp,
lineHeight = 56.sp,
fontFamily = RobotoFlex,
fontWeight = FontWeight(738),
textAlign = TextAlign.Center,
),
displayMedium = TextStyle(
fontFamily = RobotoFlex,
fontSize = 45.sp,
fontWeight = FontWeight.W400,
lineHeight = 52.sp,
),
displaySmall = TextStyle(
fontFamily = Montserrat,
fontSize = 36.sp,
fontWeight = FontWeight.W400,
lineHeight = 44.sp,
),
headlineLarge = TextStyle(
fontFamily = Montserrat,
fontSize = 32.sp,
fontWeight = FontWeight.W500,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontFamily = Montserrat,
fontSize = 28.sp,
fontWeight = FontWeight.W500,
lineHeight = 36.sp,
),
headlineSmall = TextStyle(
fontFamily = Montserrat,
fontSize = 24.sp,
fontWeight = FontWeight.W500,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontFamily = Montserrat,
fontSize = 22.sp,
fontWeight = FontWeight.W400,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontFamily = Montserrat,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Montserrat,
fontSize = 14.sp,
fontWeight = FontWeight.W500,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Montserrat,
fontSize = 14.sp,
fontWeight = FontWeight.W500,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Montserrat,
fontSize = 12.sp,
fontWeight = FontWeight.W500,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontFamily = Montserrat,
fontSize = 11.sp,
fontWeight = FontWeight.W500,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
bodyLarge = TextStyle(
fontFamily = Montserrat,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = Montserrat,
fontSize = 14.sp,
fontWeight = FontWeight.W500,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontFamily = Montserrat,
fontSize = 12.sp,
fontWeight = FontWeight.W500,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
)
================================================
FILE: Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt
================================================
/*
* 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.designsystem.theme
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.example.jetcaster.core.designsystem.R
val Montserrat = FontFamily(
Font(R.font.montserrat_light, FontWeight.Light),
Font(R.font.montserrat_regular, FontWeight.Normal),
Font(R.font.montserrat_medium, FontWeight.Medium),
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
)
val RobotoFlex = FontFamily(
Font(R.font.roboto_flex),
)
================================================
FILE: Jetcaster/core/designsystem/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#FFFFF8F4</color>
<color name="surface_bright">#FFFFF8F4</color>
</resources>
================================================
FILE: Jetcaster/core/designsystem/src/main/res/values-night/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#FF1A120A</color>
<color name="surface_bright">#FF42372D</color>
</resources>
================================================
FILE: Jetcaster/core/domain/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
namespace = "com.example.jetcaster.core.domain"
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
kotlinOptions {
jvmTarget = "17"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
coreLibraryDesugaring(libs.core.jdk.desugaring)
implementation(projects.core.data)
implementation(projects.core.dataTesting)
// Dependency injection
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Testing
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
================================================
FILE: Jetcaster/core/domain/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt
================================================
/*
* 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.core.di
import com.example.jetcaster.core.data.Dispatcher
import com.example.jetcaster.core.data.JetcasterDispatchers
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.MockEpisodePlayer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
@Module
@InstallIn(SingletonComponent::class)
object DomainDiModule {
@Provides
@Singleton
fun provideEpisodePlayer(@Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher): EpisodePlayer =
MockEpisodePlayer(mainDispatcher)
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.FilterableCategoriesModel
import com.example.jetcaster.core.model.asExternalModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Use case for categories that can be used to filter podcasts.
*/
class FilterableCategoriesUseCase @Inject constructor(private val categoryStore: CategoryStore) {
/**
* Created a [FilterableCategoriesModel] from the list of categories in [categoryStore].
* @param selectedCategory the currently selected category. If null, the first category
* returned by the backing category list will be selected in the returned
* FilterableCategoriesModel
*/
operator fun invoke(selectedCategory: CategoryInfo?): Flow<FilterableCategoriesModel> = categoryStore.categoriesSortedByPodcastCount()
.map { categories ->
FilterableCategoriesModel(
categories = categories.map { it.asExternalModel() },
selectedCategory = selectedCategory
?: categories.firstOrNull()?.asExternalModel(),
)
}
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
/**
* A use case which returns all the latest episodes from all the podcasts the user follows.
*/
class GetLatestFollowedEpisodesUseCase @Inject constructor(
private val episodeStore: EpisodeStore,
private val podcastStore: PodcastStore,
) {
@OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(): Flow<List<EpisodeToPodcast>> = podcastStore.followedPodcastsSortedByLastEpisode()
.flatMapLatest { followedPodcasts ->
episodeStore.episodesInPodcasts(
followedPodcasts.map { it.podcast.uri },
followedPodcasts.size * 5,
)
}
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.model.asExternalModel
import com.example.jetcaster.core.model.asPodcastToEpisodeInfo
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
/**
* A use case which returns top podcasts and matching episodes in a given [Category].
*/
class PodcastCategoryFilterUseCase @Inject constructor(private val categoryStore: CategoryStore) {
operator fun invoke(category: CategoryInfo?): Flow<PodcastCategoryFilterResult> {
if (category == null) {
return flowOf(PodcastCategoryFilterResult())
}
val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount(
category.id,
limit = 10,
)
val episodesFlow = categoryStore.episodesFromPodcastsInCategory(
category.id,
limit = 20,
)
// Combine our flows and collect them into the view state StateFlow
return combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes ->
PodcastCategoryFilterResult(
topPodcasts = topPodcasts.map { it.asExternalModel() },
episodes = episodes.map { it.asPodcastToEpisodeInfo() },
)
}
}
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt
================================================
/*
* 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.core.model
import com.example.jetcaster.core.data.database.model.Category
data class CategoryInfo(val id: Long, val name: String)
const val CategoryTechnology = "Technology"
fun Category.asExternalModel() = CategoryInfo(
id = id,
name = name,
)
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt
================================================
/*
* 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.core.model
import com.example.jetcaster.core.data.database.model.Episode
import java.time.Duration
import java.time.OffsetDateTime
/**
* External data layer representation of an episode.
*/
data class EpisodeInfo(
val uri: String = "",
val podcastUri: String = "",
val title: String = "",
val subTitle: String = "",
val summary: String = "",
val author: String = "",
val published: OffsetDateTime = OffsetDateTime.MIN,
val duration: Duration? = null,
val mediaUrls: List<String> = emptyList(),
)
fun Episode.asExternalModel(): EpisodeInfo = EpisodeInfo(
uri = uri,
podcastUri = podcastUri,
title = title,
subTitle = subtitle ?: "",
summary = summary ?: "",
author = author ?: "",
published = published,
duration = duration,
mediaUrls = mediaUrls,
)
fun EpisodeInfo.asDaoModel(): Episode = Episode(
uri = uri,
title = title,
subtitle = subTitle,
summary = summary,
author = author,
published = published,
duration = duration,
podcastUri = podcastUri,
mediaUrls = mediaUrls,
)
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt
================================================
/*
* 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.core.model
/**
* Model holding a list of categories and a selected category in the collection
*/
data class FilterableCategoriesModel(val categories: List<CategoryInfo> = emptyList(), val selectedCategory: CategoryInfo? = null) {
val isEmpty = categories.isEmpty() || selectedCategory == null
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt
================================================
/*
* 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.core.model
data class LibraryInfo(val episodes: List<PodcastToEpisodeInfo> = emptyList()) : List<PodcastToEpisodeInfo> by episodes
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt
================================================
/*
* 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.core.model
/**
* A model holding top podcasts and matching episodes when filtering based on a category.
*/
data class PodcastCategoryFilterResult(
val topPodcasts: List<PodcastInfo> = emptyList(),
val episodes: List<PodcastToEpisodeInfo> = emptyList(),
)
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt
================================================
/*
* 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.core.model
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import java.time.OffsetDateTime
/**
* External data layer representation of a podcast.
*/
data class PodcastInfo(
val uri: String = "",
val title: String = "",
val author: String = "",
val imageUrl: String = "",
val description: String = "",
val isSubscribed: Boolean? = null,
val lastEpisodeDate: OffsetDateTime? = null,
)
fun Podcast.asExternalModel(): PodcastInfo = PodcastInfo(
uri = this.uri,
title = this.title,
author = this.author ?: "",
imageUrl = this.imageUrl ?: "",
description = this.description ?: "",
)
fun PodcastWithExtraInfo.asExternalModel(): PodcastInfo = this.podcast.asExternalModel().copy(
isSubscribed = isFollowed,
lastEpisodeDate = lastEpisodeDate,
)
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt
================================================
/*
* 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.core.model
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
data class PodcastToEpisodeInfo(val episode: EpisodeInfo, val podcast: PodcastInfo)
fun EpisodeToPodcast.asPodcastToEpisodeInfo(): PodcastToEpisodeInfo = PodcastToEpisodeInfo(
episode = episode.asExternalModel(),
podcast = podcast.asExternalModel(),
)
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt
================================================
/*
* 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.core.player
import com.example.jetcaster.core.player.model.PlayerEpisode
import java.time.Duration
import kotlinx.coroutines.flow.StateFlow
val DefaultPlaybackSpeed = Duration.ofSeconds(1)
data class EpisodePlayerState(
val currentEpisode: PlayerEpisode? = null,
val queue: List<PlayerEpisode> = emptyList(),
val playbackSpeed: Duration = DefaultPlaybackSpeed,
val isPlaying: Boolean = false,
val timeElapsed: Duration = Duration.ZERO,
)
/**
* Interface definition for an episode player defining high-level functions such as queuing
* episodes, playing an episode, pausing, seeking, etc.
*/
interface EpisodePlayer {
/**
* A StateFlow that emits the [EpisodePlayerState] as controls as invoked on this player.
*/
val playerState: StateFlow<EpisodePlayerState>
/**
* Gets the current episode playing, or to be played, by this player.
*/
var currentEpisode: PlayerEpisode?
/**
* The speed of which the player increments
*/
var playerSpeed: Duration
fun addToQueue(episode: PlayerEpisode)
/*
* Flushes the queue
*/
fun removeAllFromQueue()
/**
* Plays the current episode
*/
fun play()
/**
* Plays the specified episode
*/
fun play(playerEpisode: PlayerEpisode)
/**
* Plays the specified list of episodes
*/
fun play(playerEpisodes: List<PlayerEpisode>)
/**
* Pauses the currently played episode
*/
fun pause()
/**
* Stops the currently played episode
*/
fun stop()
/**
* Plays another episode in the queue (if available)
*/
fun next()
/**
* Plays the previous episode in the queue (if available). Or if an episode is currently
* playing this will start the episode from the beginning
*/
fun previous()
/**
* Advances a currently played episode by a given time interval specified in [duration].
*/
fun advanceBy(duration: Duration)
/**
* Rewinds a currently played episode by a given time interval specified in [duration].
*/
fun rewindBy(duration: Duration)
/**
* Signal that user started seeking.
*/
fun onSeekingStarted()
/**
* Seeks to a given time interval specified in [duration].
*/
fun onSeekingFinished(duration: Duration)
/**
* Increases the speed of Player playback by a given time specified in [duration].
*/
fun increaseSpeed(speed: Duration = Duration.ofMillis(500))
/**
* Decreases the speed of Player playback by a given time specified in [duration].
*/
fun decreaseSpeed(speed: Duration = Duration.ofMillis(500))
}
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt
================================================
/*
* 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.core.player
import com.example.jetcaster.core.player.model.PlayerEpisode
import java.time.Duration
import kotlin.reflect.KProperty
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class MockEpisodePlayer(private val mainDispatcher: CoroutineDispatcher) : EpisodePlayer {
private val _playerState = MutableStateFlow(EpisodePlayerState())
private val _currentEpisode = MutableStateFlow<PlayerEpisode?>(null)
private val queue = MutableStateFlow<List<PlayerEpisode>>(emptyList())
private val isPlaying = MutableStateFlow(false)
private val timeElapsed = MutableStateFlow(Duration.ZERO)
private val _playerSpeed = MutableStateFlow(DefaultPlaybackSpeed)
private val coroutineScope = CoroutineScope(mainDispatcher)
private var timerJob: Job? = null
init {
coroutineScope.launch {
// Combine streams here
combine(
_currentEpisode,
queue,
isPlaying,
timeElapsed,
_playerSpeed,
) { currentEpisode, queue, isPlaying, timeElapsed, playerSpeed ->
EpisodePlayerState(
currentEpisode = currentEpisode,
queue = queue,
isPlaying = isPlaying,
timeElapsed = timeElapsed,
playbackSpeed = playerSpeed,
)
}.catch {
// TODO handle error state
throw it
}.collect {
_playerState.value = it
}
}
}
override var playerSpeed: Duration = _playerSpeed.value
override val playerState: StateFlow<EpisodePlayerState> = _playerState.asStateFlow()
override var currentEpisode: PlayerEpisode? by _currentEpisode
override fun addToQueue(episode: PlayerEpisode) {
queue.update {
it + episode
}
}
override fun removeAllFromQueue() {
queue.value = emptyList()
}
override fun play() {
// Do nothing if already playing
if (isPlaying.value) {
return
}
val episode = _currentEpisode.value ?: return
isPlaying.value = true
timerJob = coroutineScope.launch {
// Increment timer by a second
while (isActive && timeElapsed.value < episode.duration) {
delay(playerSpeed.toMillis())
timeElapsed.update { it + playerSpeed }
}
// Once done playing, see if
isPlaying.value = false
timeElapsed.value = Duration.ZERO
if (hasNext()) {
next()
}
}
}
override fun play(playerEpisode: PlayerEpisode) {
play(listOf(playerEpisode))
}
override fun play(playerEpisodes: List<PlayerEpisode>) {
if (isPlaying.value) {
pause()
}
// Keep the currently playing episode in the queue
val playingEpisode = _currentEpisode.value
var previousList: List<PlayerEpisode> = emptyList()
queue.update { queue ->
playerEpisodes.map { episode ->
if (queue.contains(episode)) {
val mutableList = queue.toMutableList()
mutableList.remove(episode)
previousList = mutableList
} else {
previousList = queue
}
}
if (playingEpisode != null) {
playerEpisodes + listOf(playingEpisode) + previousList
} else {
playerEpisodes + previousList
}
}
next()
}
override fun pause() {
isPlaying.value = false
timerJob?.cancel()
timerJob = null
}
override fun stop() {
isPlaying.value = false
timeElapsed.value = Duration.ZERO
timerJob?.cancel()
timerJob = null
}
override fun advanceBy(duration: Duration) {
val currentEpisodeDuration = _currentEpisode.value?.duration ?: return
timeElapsed.update {
(it + duration).coerceAtMost(currentEpisodeDuration)
}
}
override fun rewindBy(duration: Duration) {
timeElapsed.update {
(it - duration).coerceAtLeast(Duration.ZERO)
}
}
override fun onSeekingStarted() {
// Need to pause the player so that it doesn't compete with timeline progression.
pause()
}
override fun onSeekingFinished(duration: Duration) {
val currentEpisodeDuration = _currentEpisode.value?.duration ?: return
timeElapsed.update { duration.coerceIn(Duration.ZERO, currentEpisodeDuration) }
play()
}
override fun increaseSpeed(speed: Duration) {
_playerSpeed.value += speed
}
override fun decreaseSpeed(speed: Duration) {
_playerSpeed.value -= speed
}
override fun next() {
val q = queue.value
if (q.isEmpty()) {
return
}
timeElapsed.value = Duration.ZERO
val nextEpisode = q[0]
currentEpisode = nextEpisode
queue.value = q - nextEpisode
play()
}
override fun previous() {
timeElapsed.value = Duration.ZERO
isPlaying.value = false
timerJob?.cancel()
timerJob = null
}
private fun hasNext(): Boolean {
return queue.value.isNotEmpty()
}
}
// Used to enable property delegation
private operator fun <T> MutableStateFlow<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}
private operator fun <T> MutableStateFlow<T>.getValue(thisObj: Any?, property: KProperty<*>): T = this.value
================================================
FILE: Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt
================================================
/*
* 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.core.player.model
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastInfo
import java.time.Duration
import java.time.OffsetDateTime
/**
* Episode data with necessary information to be used within a player.
*/
data class PlayerEpisode(
val uri: String = "",
val title: String = "",
val subTitle: String = "",
val published: OffsetDateTime = OffsetDateTime.MIN,
val duration: Duration? = null,
val podcastName: String = "",
val author: String = "",
val summary: String = "",
val podcastImageUrl: String = "",
val mediaUrls: List<String> = emptyList<String>(),
) {
constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this(
title = episodeInfo.title,
subTitle = episodeInfo.subTitle,
published = episodeInfo.published,
duration = episodeInfo.duration,
podcastName = podcastInfo.title,
author = episodeInfo.author,
summary = episodeInfo.summary,
podcastImageUrl = podcastInfo.imageUrl,
uri = episodeInfo.uri,
mediaUrls = episodeInfo.mediaUrls,
)
}
fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = PlayerEpisode(
uri = episode.uri,
title = episode.title,
subTitle = episode.subtitle ?: "",
published = episode.published,
duration = episode.duration,
podcastName = podcast.title,
author = episode.author ?: podcast.author ?: "",
summary = episode.summary ?: "",
podcastImageUrl = podcast.imageUrl ?: "",
mediaUrls = episode.mediaUrls,
)
================================================
FILE: Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.testing.repository.TestCategoryStore
import com.example.jetcaster.core.model.asExternalModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class FilterableCategoriesUseCaseTest {
private val categoriesStore = TestCategoryStore()
private val testCategories = listOf(
Category(1, "News"),
Category(2, "Arts"),
Category(4, "Technology"),
Category(2, "TV & Film"),
)
val useCase = FilterableCategoriesUseCase(
categoryStore = categoriesStore,
)
@Before
fun setUp() {
categoriesStore.setCategories(testCategories)
}
@Test
fun whenNoSelectedCategory_onEmptySelectedCategoryInvoked() = runTest {
val filterableCategories = useCase(null).first()
assertEquals(
filterableCategories.categories[0],
filterableCategories.selectedCategory,
)
}
@Test
fun whenSelectedCategory_correctFilterableCategoryIsSelected() = runTest {
val selectedCategory = testCategories[2]
val filterableCategories = useCase(selectedCategory.asExternalModel()).first()
assertEquals(
selectedCategory.asExternalModel(),
filterableCategories.selectedCategory,
)
}
}
================================================
FILE: Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.testing.repository.TestEpisodeStore
import com.example.jetcaster.core.data.testing.repository.TestPodcastStore
import java.time.Duration
import java.time.OffsetDateTime
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Test
class GetLatestFollowedEpisodesUseCaseTest {
private val episodeStore = TestEpisodeStore()
private val podcastStore = TestPodcastStore()
val useCase = GetLatestFollowedEpisodesUseCase(
episodeStore = episodeStore,
podcastStore = podcastStore,
)
val testEpisodes = listOf(
Episode(
uri = "",
podcastUri = testPodcasts[0].podcast.uri,
title = "title1",
published = OffsetDateTime.MIN,
subtitle = "subtitle1",
summary = "summary1",
author = "author1",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
),
Episode(
uri = "",
podcastUri = testPodcasts[0].podcast.uri,
title = "title2",
published = OffsetDateTime.now(),
subtitle = "subtitle2",
summary = "summary2",
author = "author2",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
),
Episode(
uri = "",
podcastUri = testPodcasts[1].podcast.uri,
title = "title3",
published = OffsetDateTime.MAX,
subtitle = "subtitle3",
summary = "summary3",
author = "author3",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
),
)
@Test
fun whenNoFollowedPodcasts_emptyFlow() = runTest {
val result = useCase()
episodeStore.addEpisodes(testEpisodes)
testPodcasts.forEach {
podcastStore.addPodcast(it.podcast)
}
assertTrue(result.first().isEmpty())
}
@Test
fun whenFollowedPodcasts_nonEmptyFlow() = runTest {
val result = useCase()
episodeStore.addEpisodes(testEpisodes)
testPodcasts.forEach {
podcastStore.addPodcast(it.podcast)
}
podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri)
assertTrue(result.first().isNotEmpty())
}
@Test
fun whenFollowedPodcasts_sortedByPublished() = runTest {
val result = useCase()
episodeStore.addEpisodes(testEpisodes)
testPodcasts.forEach {
podcastStore.addPodcast(it.podcast)
}
podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri)
result.first().zipWithNext { ep1, ep2 ->
ep1.episode.published > ep2.episode.published
}.all { it }
}
}
================================================
FILE: Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt
================================================
/*
* 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.core.domain
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.data.database.model.Episode
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.database.model.Podcast
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.testing.repository.TestCategoryStore
import com.example.jetcaster.core.model.asExternalModel
import com.example.jetcaster.core.model.asPodcastToEpisodeInfo
import java.time.Duration
import java.time.OffsetDateTime
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PodcastCategoryFilterUseCaseTest {
private val categoriesStore = TestCategoryStore()
private val testEpisodeToPodcast = listOf(
EpisodeToPodcast().apply {
episode = Episode(
"",
"",
"Episode 1",
published = OffsetDateTime.now(),
subtitle = "subtitle1",
summary = "summary1",
author = "author1",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
)
_podcasts = listOf(
Podcast(
uri = "",
title = "Podcast 1",
),
)
},
EpisodeToPodcast().apply {
episode = Episode(
"",
"",
"Episode 2",
published = OffsetDateTime.now(),
subtitle = "subtitle2",
summary = "summary2",
author = "author2",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
)
_podcasts = listOf(
Podcast(
uri = "",
title = "Podcast 2",
),
)
},
EpisodeToPodcast().apply {
episode = Episode(
"",
"",
"Episode 3",
published = OffsetDateTime.now(),
subtitle = "subtitle3",
summary = "summary3",
author = "author2",
duration = Duration.ofMinutes(1),
mediaUrls = listOf("Url1"),
)
_podcasts = listOf(
Podcast(
uri = "",
title = "Podcast 3",
),
)
},
)
private val testCategory = Category(1, "Technology")
val useCase = PodcastCategoryFilterUseCase(
categoryStore = categoriesStore,
)
@Test
fun whenCategoryNull_emptyFlow() = runTest {
val resultFlow = useCase(null)
categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast)
categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts)
val result = resultFlow.first()
assertTrue(result.topPodcasts.isEmpty())
assertTrue(result.episodes.isEmpty())
}
@Test
fun whenCategoryNotNull_validFlow() = runTest {
val resultFlow = useCase(testCategory.asExternalModel())
categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast)
categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts)
val result = resultFlow.first()
assertEquals(
testPodcasts.map { it.asExternalModel() },
result.topPodcasts,
)
assertEquals(
testEpisodeToPodcast.map { it.asPodcastToEpisodeInfo() },
result.episodes,
)
}
@Test
fun whenCategoryInfoNotNull_verifyLimitFlow() = runTest {
val resultFlow = useCase(testCategory.asExternalModel())
categoriesStore.setEpisodesFromPodcast(
testCategory.id,
List(8) { testEpisodeToPodcast }.flatten(),
)
categoriesStore.setPodcastsInCategory(
testCategory.id,
List(4) { testPodcasts }.flatten(),
)
val result = resultFlow.first()
assertEquals(20, result.episodes.size)
assertEquals(10, result.topPodcasts.size)
}
}
val testPodcasts = listOf(
PodcastWithExtraInfo().apply {
podcast = Podcast(uri = "nia", title = "Now in Android")
},
PodcastWithExtraInfo().apply {
podcast = Podcast(uri = "adb", title = "Android Developers Backstage")
},
PodcastWithExtraInfo().apply {
podcast = Podcast(uri = "techcrunch", title = "Techcrunch")
},
)
================================================
FILE: Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt
================================================
/*
* 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.core.domain.player
import com.example.jetcaster.core.player.MockEpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import java.time.Duration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MockEpisodePlayerTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEpisodePlayer = MockEpisodePlayer(testDispatcher)
private val testEpisodes = listOf(
PlayerEpisode(
uri = "uri1",
duration = Duration.ofSeconds(60),
),
PlayerEpisode(
uri = "uri2",
duration = Duration.ofSeconds(60),
),
PlayerEpisode(
uri = "uri3",
duration = Duration.ofSeconds(60),
),
)
@Test
fun whenPlay_incrementsByPlaySpeed() = runTest(testDispatcher) {
val playSpeed = Duration.ofSeconds(2)
val currEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = Duration.ofSeconds(60),
)
mockEpisodePlayer.currentEpisode = currEpisode
mockEpisodePlayer.playerSpeed = playSpeed
mockEpisodePlayer.play()
advanceTimeBy(playSpeed.toMillis() + 300)
assertEquals(playSpeed, mockEpisodePlayer.playerState.value.timeElapsed)
}
@Test
fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) {
val duration = Duration.ofSeconds(60)
val currEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = duration,
)
mockEpisodePlayer.currentEpisode = currEpisode
testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) }
mockEpisodePlayer.play()
advanceTimeBy(duration.toMillis() + 1)
assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode)
}
@Test
fun whenNext_queueIsNotEmpty_autoPlaysNextEpisode() = runTest(testDispatcher) {
val duration = Duration.ofSeconds(60)
val currEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = duration,
)
mockEpisodePlayer.currentEpisode = currEpisode
testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) }
mockEpisodePlayer.next()
advanceTimeBy(100)
assertTrue(mockEpisodePlayer.playerState.value.isPlaying)
}
@Test
fun whenPlayListOfEpisodes_playerAutoPlaysNextEpisode() = runTest(testDispatcher) {
val duration = Duration.ofSeconds(60)
val currEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = duration,
)
val firstEpisodeFromList = PlayerEpisode(
uri = "firstEpisodeFromList",
duration = duration,
)
val secondEpisodeFromList = PlayerEpisode(
uri = "secondEpisodeFromList",
duration = duration,
)
val episodeListToBeAddedToTheQueue: List<PlayerEpisode> = listOf(
firstEpisodeFromList, secondEpisodeFromList,
)
mockEpisodePlayer.currentEpisode = currEpisode
mockEpisodePlayer.play(episodeListToBeAddedToTheQueue)
assertEquals(firstEpisodeFromList, mockEpisodePlayer.currentEpisode)
advanceTimeBy(duration.toMillis() + 1)
assertEquals(secondEpisodeFromList, mockEpisodePlayer.currentEpisode)
advanceTimeBy(duration.toMillis() + 1)
assertEquals(currEpisode, mockEpisodePlayer.currentEpisode)
}
@Test
fun whenNext_queueIsEmpty_doesNothing() {
val episode = testEpisodes[0]
mockEpisodePlayer.currentEpisode = episode
mockEpisodePlayer.play()
mockEpisodePlayer.next()
assertEquals(episode, mockEpisodePlayer.currentEpisode)
}
@Test
fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) {
testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) }
advanceUntilIdle()
val queue = mockEpisodePlayer.playerState.value.queue
assertEquals(testEpisodes.size, queue.size)
testEpisodes.forEachIndexed { index, playerEpisode ->
assertEquals(playerEpisode, queue[index])
}
}
@Test
fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) {
mockEpisodePlayer.currentEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = Duration.ofSeconds(60),
)
testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) }
mockEpisodePlayer.play()
advanceTimeBy(100)
mockEpisodePlayer.next()
advanceTimeBy(100)
assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode)
val queue = mockEpisodePlayer.playerState.value.queue
assertEquals(testEpisodes.size - 1, queue.size)
}
@Test
fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) {
mockEpisodePlayer.currentEpisode = PlayerEpisode(
uri = "currentEpisode",
duration = Duration.ofSeconds(60),
)
testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) }
mockEpisodePlayer.play()
advanceTimeBy(100)
mockEpisodePlayer.next()
advanceTimeBy(100)
assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode)
val queue = mockEpisodePlayer.playerState.value.queue
assertEquals(testEpisodes.size - 1, queue.size)
}
@Test
fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) {
mockEpisodePlayer.currentEpisode = testEpisodes[0]
mockEpisodePlayer.play()
advanceTimeBy(1000L)
mockEpisodePlayer.previous()
assertEquals(0, mockEpisodePlayer.playerState.value.timeElapsed.toMillis())
assertEquals(testEpisodes[0], mockEpisodePlayer.currentEpisode)
}
}
================================================
FILE: Jetcaster/core/domain-testing/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.example.jetcaster.core.domain.testing"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
kotlinOptions {
jvmTarget = "17"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(projects.core.domain)
coreLibraryDesugaring(libs.core.jdk.desugaring)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
}
================================================
FILE: Jetcaster/core/domain-testing/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
================================================
FILE: Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.core.domain.testing
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.model.PodcastToEpisodeInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import java.time.OffsetDateTime
import java.time.ZoneOffset
val PreviewCategories = listOf(
CategoryInfo(id = 1, name = "Crime"),
CategoryInfo(id = 2, name = "News"),
CategoryInfo(id = 3, name = "Comedy"),
)
val PreviewPodcasts = listOf(
PodcastInfo(
uri = "fakeUri://podcast/1",
title = "Android Developers Backstage",
author = "Android Developers",
isSubscribed = true,
lastEpisodeDate = OffsetDateTime.now(),
),
PodcastInfo(
uri = "fakeUri://podcast/2",
title = "Google Developers podcast",
author = "Google Developers",
lastEpisodeDate = OffsetDateTime.now(),
),
)
val PreviewEpisodes = listOf(
EpisodeInfo(
uri = "fakeUri://episode/1",
title = "Episode 140: Lorem ipsum dolor",
summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur " +
"Tsurkan from the System UI team about... Bubbles!",
published = OffsetDateTime.of(
2020, 6, 2, 9,
27, 0, 0, ZoneOffset.of("-0800"),
),
),
)
val PreviewPlayerEpisodes = listOf(
PlayerEpisode(
PreviewPodcasts[0],
PreviewEpisodes[0],
),
)
val PreviewPodcastEpisodes = listOf(
PodcastToEpisodeInfo(
podcast = PreviewPodcasts[0],
episode = PreviewEpisodes[0],
),
)
================================================
FILE: Jetcaster/glancewidget/build.gradle.kts
================================================
/*
* 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.
* 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.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose)
}
android {
namespace = "com.example.jetcaster.glancewidget"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
minSdk =
libs.versions.minSdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
buildConfig = true
}
kotlinOptions {
jvmTarget = "17"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.glance)
implementation(libs.coil.kt.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.android.material3)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(projects.core.designsystem)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
================================================
FILE: Jetcaster/glancewidget/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="com.example.jetcaster.glancewidget.JetcasterAppWidgetReceiver"
android:enabled="true"
android:label="@string/app_widget_description"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/jetcaster_info" />
</receiver>
</application>
</manifest>
================================================
FILE: Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt
================================================
/*
* 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.glancewidget
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import com.example.jetcaster.designsystem.theme.backgroundDark
import com.example.jetcaster.designsystem.theme.backgroundLight
import com.example.jetcaster.designsystem.theme.errorContainerDark
import com.example.jetcaster.designsystem.theme.errorContainerLight
import com.example.jetcaster.designsystem.theme.errorDark
import com.example.jetcaster.designsystem.theme.errorLight
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight
import com.example.jetcaster.designsystem.theme.inversePrimaryDark
import com.example.jetcaster.designsystem.theme.inversePrimaryLight
import com.example.jetcaster.designsystem.theme.inverseSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseSurfaceLight
import com.example.jetcaster.designsystem.theme.onBackgroundDark
import com.example.jetcaster.designsystem.theme.onBackgroundLight
import com.example.jetcaster.designsystem.theme.onErrorContainerDark
import com.example.jetcaster.designsystem.theme.onErrorContainerLight
import com.example.jetcaster.designsystem.theme.onErrorDark
import com.example.jetcaster.designsystem.theme.onErrorLight
import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark
import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight
import com.example.jetcaster.designsystem.theme.onPrimaryDark
import com.example.jetcaster.designsystem.theme.onPrimaryLight
import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark
import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight
import com.example.jetcaster.designsystem.theme.onSecondaryDark
import com.example.jetcaster.designsystem.theme.onSecondaryLight
import com.example.jetcaster.designsystem.theme.onSurfaceDark
import com.example.jetcaster.designsystem.theme.onSurfaceLight
import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark
import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight
import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark
import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight
import com.example.jetcaster.designsystem.theme.onTertiaryDark
import com.example.jetcaster.designsystem.theme.onTertiaryLight
import com.example.jetcaster.designsystem.theme.outlineDark
import com.example.jetcaster.designsystem.theme.outlineLight
import com.example.jetcaster.designsystem.theme.outlineVariantDark
import com.example.jetcaster.designsystem.theme.outlineVariantLight
import com.example.jetcaster.designsystem.theme.primaryContainerDark
import com.example.jetcaster.designsystem.theme.primaryContainerLight
import com.example.jetcaster.designsystem.theme.primaryDark
import com.example.jetcaster.designsystem.theme.primaryLight
import com.example.jetcaster.designsystem.theme.scrimDark
import com.example.jetcaster.designsystem.theme.scrimLight
import com.example.jetcaster.designsystem.theme.secondaryContainerDark
import com.example.jetcaster.designsystem.theme.secondaryContainerLight
import com.example.jetcaster.designsystem.theme.secondaryDark
import com.example.jetcaster.designsystem.theme.secondaryLight
import com.example.jetcaster.designsystem.theme.surfaceBrightDark
import com.example.jetcaster.designsystem.theme.surfaceBrightLight
import com.example.jetcaster.designsystem.theme.surfaceContainerDark
import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark
import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark
import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight
import com.example.jetcaster.designsystem.theme.surfaceDark
import com.example.jetcaster.designsystem.theme.surfaceDimDark
import com.example.jetcaster.designsystem.theme.surfaceDimLight
import com.example.jetcaster.designsystem.theme.surfaceLight
import com.example.jetcaster.designsystem.theme.surfaceVariantDark
import com.example.jetcaster.designsystem.theme.surfaceVariantLight
import com.example.jetcaster.designsystem.theme.tertiaryContainerDark
import com.example.jetcaster.designsystem.theme.tertiaryContainerLight
import com.example.jetcaster.designsystem.theme.tertiaryDark
import com.example.jetcaster.designsystem.theme.tertiaryLight
/**
* Todo, this is copied from the core module. Refactor colors out of that so we can reference them.
*/
private val lightJetcasterColors = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
/**
* Todo, this is copied from the core module. Refactor colors out of that so we can reference them.
*/
internal val DarkJetcasterColors = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
================================================
FILE: Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt
================================================
/*
* Copyright 2024-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.
* 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.glancewidget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.components.Scaffold
import androidx.glance.appwidget.components.SquareIconButton
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import coil.ImageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
internal val TAG = "JetcasterAppWidget"
/**
* Implementation of App Widget functionality.
*/
class JetcasterAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = JetcasterAppWidget()
}
data class JetcasterAppWidgetViewState(val episodeTitle: String, val podcastTitle: String, val isPlaying: Boolean, val albumArtUri: String)
private object Sizes {
val short = 72.dp
val minWidth = 140.dp
val smallBucketCutoffWidth = 250.dp // anything from minWidth to this will have no title
val normal = 80.dp
val medium = 56.dp
val condensed = 48.dp
}
private enum class SizeBucket { Invalid, Narrow, Normal, NarrowShort, NormalShort }
@Composable
private fun calculateSizeBucket(): SizeBucket {
val size: DpSize = LocalSize.current
val width = size.width
val height = size.height
return when {
width < Sizes.minWidth -> SizeBucket.Invalid
width <= Sizes.smallBucketCutoffWidth ->
if (height >= Sizes.short) SizeBucket.Narrow else SizeBucket.NarrowShort
else ->
if (height >= Sizes.short) SizeBucket.Normal else SizeBucket.NormalShort
}
}
class JetcasterAppWidget : GlanceAppWidget() {
override val sizeMode: SizeMode
get() = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val testState = JetcasterAppWidgetViewState(
episodeTitle =
"100 - Android 15 DP 1, Stable Studio Iguana, Cloud Photo Picker, and more!",
podcastTitle = "Now in Android",
isPlaying = false,
albumArtUri = "https://static.libsyn.com/p/assets/9/f/f/3/" +
"9ff3cb5dc6cfb3e2e5bbc093207a2619/NIA000_PodcastThumbnail.png",
)
provideContent {
val sizeBucket = calculateSizeBucket()
val playPauseIcon = if (testState.isPlaying) PlayPauseIcon.Pause else PlayPauseIcon.Play
val artUri = Uri.parse(testState.albumArtUri)
GlanceTheme {
when (sizeBucket) {
SizeBucket.Invalid -> WidgetUiInvalidSize()
SizeBucket.Narrow -> Widget(
iconSize = Sizes.medium,
imageUri = artUri,
playPauseIcon = playPauseIcon,
)
SizeBucket.Normal -> WidgetUiNormal(
iconSize = Sizes.normal,
title = testState.episodeTitle,
subtitle = testState.podcastTitle,
imageUri = artUri,
playPauseIcon = playPauseIcon,
)
SizeBucket.NarrowShort -> Widget(
iconSize = Sizes.condensed,
imageUri = artUri,
playPauseIcon = playPauseIcon,
)
SizeBucket.NormalShort -> WidgetUiNormal(
iconSize = Sizes.condensed,
title = testState.episodeTitle,
subtitle = testState.podcastTitle,
imageUri = artUri,
playPauseIcon = playPauseIcon,
)
}
}
}
}
}
@Composable
private fun WidgetUiNormal(title: String, subtitle: String, imageUri: Uri, playPauseIcon: PlayPauseIcon, iconSize: Dp) {
Scaffold {
Row(
GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
) {
AlbumArt(imageUri, GlanceModifier.size(iconSize))
PodcastText(title, subtitle, modifier = GlanceModifier.padding(16.dp).defaultWeight())
PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {})
}
}
}
@Composable
private fun Widget(iconSize: Dp, imageUri: Uri, playPauseIcon: PlayPauseIcon) {
/* title bar will be optional in scaffold in glance 1.1.0-beta3*/
Scaffold(titleBar = {}) {
Row(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
) {
AlbumArt(imageUri, GlanceModifier.size(iconSize))
Spacer(GlanceModifier.defaultWeight())
PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {})
}
}
}
@Composable
private fun WidgetUiInvalidSize() {
Box(modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color.Magenta))) {
Text("invalid size")
}
}
@Composable
private fun AlbumArt(imageUri: Uri, modifier: GlanceModifier = GlanceModifier) {
WidgetAsyncImage(uri = imageUri, contentDescription = null, modifier = modifier)
}
@Composable
fun PodcastText(title: String, subtitle: String, modifier: GlanceModifier = GlanceModifier) {
val fgColor = GlanceTheme.colors.onPrimaryContainer
val size = LocalSize.current
when {
size.height >= Sizes.short -> Column(modifier) {
Text(
text = title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = fgColor,
),
maxLines = 2,
)
Text(
text = subtitle,
style = TextStyle(fontSize = 14.sp, color = fgColor),
maxLines = 2,
)
}
else -> Column(modifier) {
Text(
text = title,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = fgColor,
),
maxLines = 1,
)
}
}
}
@Composable
private fun PlayPauseButton(modifier: GlanceModifier = GlanceModifier.size(Sizes.normal), state: PlayPauseIcon, onClick: () -> Unit) {
val (iconRes: Int, description: Int) = when (state) {
PlayPauseIcon.Play -> R.drawable.outline_play_arrow_24 to R.string.content_description_play
PlayPauseIcon.Pause -> R.drawable.outline_pause_24 to R.string.content_description_pause
}
val provider = ImageProvider(iconRes)
val contentDescription = LocalContext.current.getString(description)
SquareIconButton(
modifier = modifier,
imageProvider = provider,
contentDescription = contentDescription,
onClick = onClick,
)
}
enum class PlayPauseIcon { Play, Pause }
/**
* Uses Coil to load images.
*/
@Composable
private fun WidgetAsyncImage(uri: Uri, contentDescription: String?, modifier: GlanceModifier = GlanceModifier) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = uri) {
val request = ImageRequest.Builder(context)
.data(uri)
.size(200, 200)
.target { data: Drawable ->
bitmap = (data as BitmapDrawable).bitmap
}
.build()
scope.launch(Dispatchers.IO) {
val result = ImageLoader(context).execute(request)
if (result is ErrorResult) {
val t = result.throwable
Log.e(TAG, "Image request error:", t)
}
}
}
bitmap?.let { bitmap ->
Image(
provider = ImageProvider(bitmap),
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = modifier.cornerRadius(12.dp), // TODO: confirm radius with design
)
}
}
================================================
FILE: Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt
================================================
/*
* Copyright 2024-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.
* 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.glancewidget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.components.Scaffold
import androidx.glance.appwidget.components.SquareIconButton
import androidx.glance.appwidget.compose
import androidx.glance.appwidget.provideContent
import androidx.glance.layout.Alignment
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import androidx.glance.layout.wrapContentSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private object SizesPreview {
val medium = 56.dp
}
/**
* This is a convenience function for updating the widget preview using Generated Previews.
*
* In a real application, this would be called whenever the widget's state changes.
*/
fun updateWidgetPreview(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
CoroutineScope(Dispatchers.IO).launch {
try {
val appwidgetManager = AppWidgetManager.getInstance(context)
appwidgetManager.setWidgetPreview(
ComponentName(context, JetcasterAppWidgetReceiver::class.java),
AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN,
JetcasterAppWidgetPreview().compose(
context,
size = DpSize(160.dp, 64.dp),
),
)
} catch (e: Exception) {
Log.e(TAG, e.message, e)
}
}
}
}
class JetcasterAppWidgetPreview : GlanceAppWidget() {
override val sizeMode: SizeMode
get() = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
Widget()
}
}
}
}
@Composable
private fun Widget() {
Scaffold {
Row(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
) {
Image(
modifier = GlanceModifier.wrapContentSize().size(SizesPreview.medium),
provider = ImageProvider(R.drawable.widget_preview_thumbnail),
contentDescription = "",
)
Spacer(GlanceModifier.defaultWeight())
SquareIconButton(
modifier = GlanceModifier.size(SizesPreview.medium),
imageProvider = ImageProvider(R.drawable.outline_play_arrow_24),
contentDescription = "",
onClick = { },
)
}
}
}
================================================
FILE: Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!-- This file provides an XML preview layout for the widget. This file enables dynamic color
and dark mode support in Widget previews between android 12 and 15. Android 15+ uses Generated Previews.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@android:id/background"
android:background="@color/colorAppWidgetBackground"
android:theme="@style/MyWidgetTheme"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp"
android:layout_gravity="center_vertical"
android:orientation="horizontal">
<RelativeLayout
android:layout_width="@dimen/widget_preview_icon_height"
android:layout_height="@dimen/widget_preview_icon_height"
android:background="@drawable/widget_preview_image_shape"
android:clipToOutline="true"
android:layout_gravity="left|center_vertical"
android:outlineProvider="background">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerInside"
android:src="@drawable/widget_preview_thumbnail"/>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"/>
<RelativeLayout
android:layout_width="@dimen/widget_preview_icon_height"
android:layout_height="@dimen/widget_preview_icon_height"
android:background="@drawable/widget_preview_image_shape"
android:clipToOutline="true"
android:layout_gravity="right|center_vertical"
android:outlineProvider="background">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorPrimary"
android:padding="8dp"
android:scaleType="centerInside"
android:src="@drawable/outline_play_arrow_24"
/>
</RelativeLayout>
</LinearLayout>
</FrameLayout>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (C) 2023 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
~
~ http://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.
-->
<resources>
<color name="light_purple">#FFECDCFF</color>
<color name="aqua">#FF7CD7BA</color>
<color name="dark_gray">#FF2C322F</color>
<color name="colorAppWidgetBackground">#ffe0f3ff</color>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values/sizes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="widget_preview_icon_height">80dp</dimen>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values/strings.xml
================================================
<resources>
<string name="app_widget_description">Play your podcasts</string>
<string name="content_description_play">Play</string>
<string name="content_description_pause">Pause</string>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values/styles.xml
================================================
<!--
~ Copyright (C) 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
~
~ http://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.
-->
<resources>
<style name="MyWidgetTheme" parent="Theme.Material3.DynamicColors.DayNight">
<!-- Override default colorBackground attribute with custom color. -->
<item name="colorSurface">@color/light_purple</item>
</style>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="widget_preview_icon_height">58dp</dimen>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values-night/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (C) 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
~
~ http://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.
-->
<resources>
<color name="colorAppWidgetBackground">#ff20333d</color>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (C) 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
~
~ http://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.
-->
<resources>
<color name="colorAppWidgetBackground">@android:color/system_accent2_800</color>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values-v31/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (C) 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
~
~ http://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.
-->
<resources>
<color name="colorAppWidgetBackground">@android:color/system_accent2_50</color>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/values-v31/styles.xml
================================================
<!--
~ Copyright (C) 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
~
~ http://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.
-->
<resources>
<!-- Do not override any color attribute. -->
<style name="MyWidgetTheme" parent="Theme.Material3.DynamicColors.DayNight" >
</style>
</resources>
================================================
FILE: Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:minWidth="140dp"
android:minHeight="48dp"
android:minResizeWidth="140dp"
android:minResizeHeight="48dp"
android:maxResizeWidth="520dp"
android:maxResizeHeight="64dp"
android:resizeMode="horizontal"
android:initialLayout="@layout/glance_default_loading_layout"
android:previewImage="@drawable/widget_preview"
android:previewLayout="@layout/widget_preview"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" />
================================================
FILE: Jetcaster/mobile/build.gradle.kts
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.compose)
}
android {
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
namespace = "com.example.jetcaster"
defaultConfig {
applicationId = "com.example.jetcaster"
minSdk =
libs.versions.minSdk
.get()
.toInt()
targetSdk =
libs.versions.targetSdk
.get()
.toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
// Important: change the keystore for a production deployment
val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore")
val localKeystore = rootProject.file("debug_2.keystore")
val hasKeyInfo = userKeystore.exists()
create("release") {
// get from env variables
storeFile = if (hasKeyInfo) userKeystore else localKeystore
storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password")
keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias")
keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password")
}
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging.resources {
// The Rome library JARs embed some internal utils libraries in nested JARs.
// We don't need them so we exclude them in the final package.
excludes += "/*.jar"
// Multiple dependency bring these files in. Exclude them to enable
// our test APK to build (has no effect on our AARs)
excludes += "/META-INF/AL2.0"
excludes += "/META-INF/LGPL2.1"
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.palette)
// Dependency injection
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Compose
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.window)
implementation(libs.androidx.window.core)
implementation(libs.accompanist.adaptive)
implementation(libs.coil.kt.compose)
implementation(projects.core.data)
implementation(projects.core.designsystem)
implementation(projects.core.domain)
implementation(projects.glancewidget)
implementation(projects.core.domainTesting)
coreLibraryDesugaring(libs.core.jdk.desugaring)
}
================================================
FILE: Jetcaster/mobile/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Uses ACCESS_NETWORK_STATE to check if the device is connected to internet or not -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Uses INTERNET to fetch RSS feed + images -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".JetcasterApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Jetcaster"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true">
<activity
android:name="com.example.jetcaster.ui.MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.Jetcaster"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* Application which sets up our dependency [Graph] with a context.
*/
@HiltAndroidApp
class JetcasterApplication :
Application(),
ImageLoaderFactory {
@Inject lateinit var imageLoader: ImageLoader
override fun newImageLoader(): ImageLoader = imageLoader
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt
================================================
/*
* Copyright 2020-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.
* 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.
*/
@file:OptIn(ExperimentalSharedTransitionApi::class)
package com.example.jetcaster.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.scaleOut
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.window.layout.DisplayFeature
import com.example.jetcaster.R
import com.example.jetcaster.ui.home.MainScreen
import com.example.jetcaster.ui.player.PlayerScreen
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun JetcasterApp(displayFeatures: List<DisplayFeature>, appState: JetcasterAppState = rememberJetcasterAppState()) {
val adaptiveInfo = currentWindowAdaptiveInfo()
if (appState.isOnline) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this,
) {
NavHost(
navController = appState.navController,
startDestination = Screen.Home.route,
popExitTransition = { scaleOut(targetScale = 0.9f) },
popEnterTransition = { EnterTransition.None },
) {
composable(Screen.Home.route) { backStackEntry ->
CompositionLocalProvider(
LocalAnimatedVisibilityScope provides this,
) {
MainScreen(
windowSizeClass = adaptiveInfo.windowSizeClass,
navigateToPlayer = { episode ->
appState.navigateToPlayer(episode.uri, backStackEntry)
},
)
}
}
composable(Screen.Player.route) {
CompositionLocalProvider(
LocalAnimatedVisibilityScope provides this,
) {
PlayerScreen(
windowSizeClass = adaptiveInfo.windowSizeClass,
displayFeatures = displayFeatures,
onBackPress = appState::navigateBack,
)
}
}
}
}
}
} else {
OfflineDialog { appState.refreshOnline() }
}
}
@Composable
fun OfflineDialog(onRetry: () -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text(text = stringResource(R.string.connection_error_title)) },
text = { Text(text = stringResource(R.string.connection_error_message)) },
confirmButton = {
TextButton(onClick = onRetry) {
Text(stringResource(R.string.retry_label))
}
},
)
}
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt
================================================
/*
* 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.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
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.platform.LocalContext
import androidx.core.content.ContextCompat.getSystemService
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
/**
* List of screens for [JetcasterApp]
*/
sealed class Screen(val route: String) {
object Home : Screen("home")
object Player : Screen("player/{$ARG_EPISODE_URI}") {
fun createRoute(episodeUri: String) = "player/$episodeUri"
}
object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}") {
fun createRoute(podcastUri: String) = "podcast/$podcastUri"
}
companion object {
val ARG_PODCAST_URI = "podcastUri"
val ARG_EPISODE_URI = "episodeUri"
}
}
@Composable
fun rememberJetcasterAppState(navController: NavHostController = rememberNavController(), context: Context = LocalContext.current) =
remember(navController, context) {
JetcasterAppState(navController, context)
}
class JetcasterAppState(val navController: NavHostController, private val context: Context) {
var isOnline by mutableStateOf(checkIfOnline())
private set
fun refreshOnline() {
isOnline = checkIfOnline()
}
fun navigateToPlayer(episodeUri: String, from: NavBackStackEntry) {
// In order to discard duplicated navigation events, we check the Lifecycle
if (from.lifecycleIsResumed()) {
val encodedUri = Uri.encode(episodeUri)
navController.navigate(Screen.Player.createRoute(encodedUri))
}
}
fun navigateToPodcastDetails(podcastUri: String, from: NavBackStackEntry) {
if (from.lifecycleIsResumed()) {
val encodedUri = Uri.encode(podcastUri)
navController.navigate(Screen.PodcastDetails.createRoute(encodedUri))
}
}
fun navigateBack() {
navController.popBackStack()
}
@Suppress("DEPRECATION")
private fun checkIfOnline(): Boolean {
val cm = getSystemService(context, ConnectivityManager::class.java)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val capabilities = cm?.getNetworkCapabilities(cm.activeNetwork) ?: return false
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} else {
cm?.activeNetworkInfo?.isConnectedOrConnecting == true
}
}
}
/**
* If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event.
*
* This is used to de-duplicate navigation events.
*/
private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt
================================================
/*
* Copyright 2020-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.
* 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
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.jetcaster.glancewidget.updateWidgetPreview
import com.example.jetcaster.ui.theme.JetcasterTheme
import com.google.accompanist.adaptive.calculateDisplayFeatures
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
updateWidgetPreview(this)
setContent {
val displayFeatures = calculateDisplayFeatures(this)
JetcasterTheme {
JetcasterApp(
displayFeatures,
)
}
}
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt
================================================
/*
* Copyright 2020-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.
* 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.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarColors
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.allVerticalHingeBounds
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.HingePolicy
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold
import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator
import androidx.compose.material3.adaptive.occludingVerticalHingeBounds
import androidx.compose.material3.adaptive.separatingVerticalHingeBounds
import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel
import androidx.compose.material3.carousel.rememberCarouselState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
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.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import com.example.jetcaster.R
import com.example.jetcaster.core.domain.testing.PreviewCategories
import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes
import com.example.jetcaster.core.domain.testing.PreviewPodcasts
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.FilterableCategoriesModel
import com.example.jetcaster.core.model.LibraryInfo
import com.example.jetcaster.core.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.ui.home.discover.discoverItems
import com.example.jetcaster.ui.home.library.libraryItems
import com.example.jetcaster.ui.podcast.PodcastDetailsScreen
import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel
import com.example.jetcaster.ui.theme.JetcasterTheme
import com.example.jetcaster.ui.tooling.DevicePreviews
import com.example.jetcaster.util.ToggleFollowPodcastIconButton
import com.example.jetcaster.util.fullWidthItem
import com.example.jetcaster.util.isCompact
import com.example.jetcaster.util.quantityStringResource
import com.example.jetcaster.util.radialGradientScrim
import java.time.Duration
import java.time.LocalDateTime
import java.time.OffsetDateTime
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isMainPaneHidden(): Boolean =
scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden
/**
* Copied from `calculatePaneScaffoldDirective()` in [PaneScaffoldDirective], with modifications to
* only show 1 pane horizontally if either width or height size class is compact.
*/
fun calculateScaffoldDirective(
windowAdaptiveInfo: WindowAdaptiveInfo,
verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating,
): PaneScaffoldDirective {
val maxHorizontalPartitions: Int
val verticalSpacerSize: Dp
if (windowAdaptiveInfo.windowSizeClass.isCompact) {
// Window width or height is compact. Limit to 1 pane horizontally.
maxHorizontalPartitions = 1
verticalSpacerSize = 0.dp
} else {
when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> {
maxHorizontalPartitions = 1
verticalSpacerSize = 0.dp
}
WindowWidthSizeClass.MEDIUM -> {
maxHorizontalPartitions = 1
verticalSpacerSize = 0.dp
}
else -> {
maxHorizontalPartitions = 2
verticalSpacerSize = 24.dp
}
}
}
val maxVerticalPartitions: Int
val horizontalSpacerSize: Dp
if (windowAdaptiveInfo.windowPosture.isTabletop) {
maxVerticalPartitions = 2
horizontalSpacerSize = 24.dp
} else {
maxVerticalPartitions = 1
horizontalSpacerSize = 0.dp
}
val defaultPanePreferredWidth = 360.dp
return PaneScaffoldDirective(
maxHorizontalPartitions,
verticalSpacerSize,
maxVerticalPartitions,
horizontalSpacerSize,
defaultPanePreferredWidth,
getExcludedVerticalBounds(windowAdaptiveInfo.windowPosture, verticalHingePolicy),
)
}
/**
* Copied from `getExcludedVerticalBounds()` in [PaneScaffoldDirective] since it is private.
*/
private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy): List<Rect> = when (hingePolicy) {
HingePolicy.AvoidSeparating -> posture.separatingVerticalHingeBounds
HingePolicy.AvoidOccluding -> posture.occludingVerticalHingeBounds
HingePolicy.AlwaysAvoid -> posture.allVerticalHingeBounds
else -> emptyList()
}
@Composable
fun MainScreen(windowSizeClass: WindowSizeClass, navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = hiltViewModel()) {
val homeScreenUiState by viewModel.state.collectAsStateWithLifecycle()
val uiState = homeScreenUiState
Box {
HomeScreenReady(
uiState = uiState,
windowSizeClass = windowSizeClass,
navigateToPlayer = navigateToPlayer,
viewModel = viewModel,
)
if (uiState.errorMessage != null) {
HomeScreenError(onRetry = viewModel::refresh)
}
}
}
@Composable
private fun HomeScreenError(onRetry: () -> Unit, modifier: Modifier = Modifier) {
Surface(modifier = modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
Text(
text = stringResource(id = R.string.an_error_has_occurred),
modifier = Modifier.padding(16.dp),
)
Button(onClick = onRetry) {
Text(text = stringResource(id = R.string.retry_label))
}
}
}
}
@Preview
@Composable
fun HomeScreenErrorPreview() {
JetcasterTheme {
HomeScreenError(onRetry = {})
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun HomeScreenReady(
uiState: HomeScreenUiState,
windowSizeClass: WindowSizeClass,
navigateToPlayer: (EpisodeInfo) -> Unit,
viewModel: HomeViewModel = hiltViewModel(),
) {
val navigator = rememberSupportingPaneScaffoldNavigator<String>(
scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()),
)
val scope = rememberCoroutineScope()
BackHandler(enabled = navigator.canNavigateBack()) {
scope.launch {
navigator.navigateBack()
}
}
Surface {
SupportingPaneScaffold(
value = navigator.scaffoldValue,
directive = navigator.scaffoldDirective,
mainPane = {
HomeScreen(
windowSizeClass = windowSizeClass,
isLoading = uiState.isLoading,
featuredPodcasts = uiState.featuredPodcasts,
homeCategories = uiState.homeCategories,
selectedHomeCategory = uiState.selectedHomeCategory,
filterableCategoriesModel = uiState.filterableCategoriesModel,
podcastCategoryFilterResult = uiState.podcastCategoryFilterResult,
library = uiState.library,
onHomeAction = viewModel::onHomeAction,
navigateToPodcastDetails = {
scope.launch {
navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri)
}
},
navigateToPlayer = navigateToPlayer,
modifier = Modifier.fillMaxSize(),
)
},
supportingPane = {
val podcastUri = navigator.currentDestination?.contentKey
if (!podcastUri.isNullOrEmpty()) {
val podcastDetailsViewModel =
hiltViewModel<PodcastDetailsViewModel, PodcastDetailsViewModel.Factory>(
key = podcastUri,
) {
it.create(podcastUri)
}
PodcastDetailsScreen(
viewModel = podcastDetailsViewModel,
navigateToPlayer = navigateToPlayer,
navigateBack = {
if (navigator.canNavigateBack()) {
scope.launch {
navigator.navigateBack()
}
}
},
showBackButton = navigator.isMainPaneHidden(),
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeAppBar(isExpanded: Boolean, modifier: Modifier = Modifier) {
var queryText by remember {
mutableStateOf("")
}
Row(
horizontalArrangement = Arrangement.End,
modifier = modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = queryText,
onQueryChange = { queryText = it },
onSearch = {},
expanded = false,
onExpandedChange = {},
enabled = true,
placeholder = {
Text(stringResource(id = R.string.search_for_a_podcast))
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = stringResource(R.string.cd_account),
)
},
interactionSource = null,
modifier = if (isExpanded) Modifier.fillMaxWidth() else Modifier,
)
},
expanded = false,
onExpandedChange = {},
) {}
}
}
@Composable
private fun HomeScreenBackground(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.background),
) {
Box(
modifier = Modifier
.fillMaxSize()
.radialGradientScrim(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)),
)
content()
}
}
@Composable
private fun HomeScreen(
windowSizeClass: WindowSizeClass,
isLoading: Boolean,
featuredPodcasts: PersistentList<PodcastInfo>,
selectedHomeCategory: HomeCategory,
homeCategories: List<HomeCategory>,
filterableCategoriesModel: FilterableCategoriesModel,
podcastCategoryFilterResult: PodcastCategoryFilterResult,
library: LibraryInfo,
onHomeAction: (HomeAction) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
modifier: Modifier = Modifier,
) {
// Effect that changes the home category selection when there are no subscribed podcasts
LaunchedEffect(key1 = featuredPodcasts) {
if (featuredPodcasts.isEmpty()) {
onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Discover))
}
}
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
HomeScreenBackground(
modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars),
) {
Scaffold(
topBar = {
Column {
HomeAppBar(
isExpanded = windowSizeClass.isCompact,
modifier = Modifier.fillMaxWidth(),
)
if (isLoading) {
LinearProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
containerColor = Color.Transparent,
) { contentPadding ->
// Main Content
val snackBarText = stringResource(id = R.string.episode_added_to_your_queue)
val showHomeCategoryTabs = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty()
HomeContent(
featuredPodcasts = featuredPodcasts,
selectedHomeCategory = selectedHomeCategory,
filterableCategoriesModel = filterableCategoriesModel,
podcastCategoryFilterResult = podcastCategoryFilterResult,
library = library,
modifier = Modifier.padding(contentPadding),
onHomeAction = { action ->
if (action is HomeAction.QueueEpisode) {
coroutineScope.launch {
snackbarHostState.showSnackbar(snackBarText)
}
}
onHomeAction(action)
},
navigateToPodcastDetails = navigateToPodcastDetails,
navigateToPlayer = navigateToPlayer,
)
if (showHomeCategoryTabs) {
PillToolbar(
selectedHomeCategory,
onHomeAction,
Modifier.align(Alignment.BottomCenter),
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PillToolbar(selectedHomeCategory: HomeCategory, onHomeAction: (HomeAction) -> Unit, modifier: Modifier = Modifier) {
HorizontalFloatingToolbar(
modifier = modifier,
colors = FloatingToolbarColors(
toolbarContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
fabContainerColor = MaterialTheme.colorScheme.tertiary,
fabContentColor = MaterialTheme.colorScheme.onTertiary,
),
expanded = true,
content = {
val libraryContainerColor =
if (selectedHomeCategory.name == HomeCategory.Library.name) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
val libraryContentColor =
if (selectedHomeCategory.name == HomeCategory.Library.name) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Button(
onClick = { onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Library)) },
colors = ButtonColors(
containerColor = libraryContainerColor,
contentColor = libraryContentColor,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Row(Modifier) {
Icon(
Icons.Filled.LibraryMusic,
modifier = Modifier.padding(end = 8.dp),
contentDescription = stringResource(
R.string.library_toolbar_content_description,
),
)
Text(stringResource(R.string.library_toolbar))
}
}
val discoverContainerColor =
if (selectedHomeCategory.name == HomeCategory.Library.name) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.secondary
}
val discoverContentColor =
if (selectedHomeCategory.name == HomeCategory.Library.name) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
Button(
onClick = { onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Discover)) },
colors = ButtonColors(
containerColor = discoverContainerColor,
contentColor = discoverContentColor,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.surfaceContainerHighest,
),
) {
Row {
Icon(
painterResource(R.drawable.genres),
modifier = Modifier.padding(end = 8.dp),
contentDescription = stringResource(
R.string.discover_toolbar_content_description,
),
)
Text(stringResource(R.string.discover_toolbar))
}
}
},
)
}
@Composable
private fun HomeContent(
featuredPodcasts: PersistentList<PodcastInfo>,
selectedHomeCategory: HomeCategory,
filterableCategoriesModel: FilterableCategoriesModel,
podcastCategoryFilterResult: PodcastCategoryFilterResult,
library: LibraryInfo,
modifier: Modifier = Modifier,
onHomeAction: (HomeAction) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
) {
val pagerState = rememberPagerState { featuredPodcasts.size }
LaunchedEffect(pagerState, featuredPodcasts) {
snapshotFlow { pagerState.currentPage }
.collect {
val podcast = featuredPodcasts.getOrNull(it)
onHomeAction(HomeAction.LibraryPodcastSelected(podcast))
}
}
HomeContentGrid(
featuredPodcasts = featuredPodcasts,
selectedHomeCategory = selectedHomeCategory,
filterableCategoriesModel = filterableCategoriesModel,
podcastCategoryFilterResult = podcastCategoryFilterResult,
library = library,
modifier = modifier,
onHomeAction = onHomeAction,
navigateToPodcastDetails = navigateToPodcastDetails,
navigateToPlayer = navigateToPlayer,
)
}
@Composable
private fun HomeContentGrid(
featuredPodcasts: PersistentList<PodcastInfo>,
selectedHomeCategory: HomeCategory,
filterableCategoriesModel: FilterableCategoriesModel,
podcastCategoryFilterResult: PodcastCategoryFilterResult,
library: LibraryInfo,
modifier: Modifier = Modifier,
onHomeAction: (HomeAction) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(362.dp),
modifier = modifier.fillMaxSize(),
) {
when (selectedHomeCategory) {
HomeCategory.Library -> {
if (featuredPodcasts.isNotEmpty()) {
fullWidthItem {
FollowedPodcastItem(
items = featuredPodcasts,
onPodcastUnfollowed = {
onHomeAction(HomeAction.PodcastUnfollowed(it))
},
navigateToPodcastDetails = navigateToPodcastDetails,
modifier = Modifier
.fillMaxWidth(),
)
}
}
libraryItems(
library = library,
navigateToPlayer = navigateToPlayer,
onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) },
removeFromQueue = { onHomeAction(HomeAction.RemoveEpisode(it)) },
)
}
HomeCategory.Discover -> {
discoverItems(
filterableCategoriesModel = filterableCategoriesModel,
podcastCategoryFilterResult = podcastCategoryFilterResult,
navigateToPodcastDetails = navigateToPodcastDetails,
navigateToPlayer = navigateToPlayer,
onCategorySelected = { onHomeAction(HomeAction.CategorySelected(it)) },
onTogglePodcastFollowed = {
onHomeAction(HomeAction.TogglePodcastFollowed(it))
},
onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) },
removeFromQueue = { onHomeAction(HomeAction.RemoveEpisode(it)) },
)
}
}
}
}
@Composable
private fun FollowedPodcastItem(
items: PersistentList<PodcastInfo>,
onPodcastUnfollowed: (PodcastInfo) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Spacer(Modifier.height(16.dp))
FollowedPodcasts(
items = items,
onPodcastUnfollowed = onPodcastUnfollowed,
navigateToPodcastDetails = navigateToPodcastDetails,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FollowedPodcasts(
items: PersistentList<PodcastInfo>,
onPodcastUnfollowed: (PodcastInfo) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
) {
// TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute
// the content padding. This should be revisited once a carousel component is available.
// Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition`
// which solves this problem and avoids this calculation altogether. Once 1.7.0 is
// stable, this implementation can be updated.
BoxWithConstraints(
modifier = modifier.background(Color.Transparent),
) {
val horizontalPadding = this.maxWidth
HorizontalMultiBrowseCarousel(
state = rememberCarouselState { items.count() },
preferredItemWidth = 205.dp,
itemSpacing = 12.dp,
contentPadding = PaddingValues(8.dp),
) { page ->
val podcast = items[page]
FollowedPodcastCarouselItem(
podcastImageUrl = podcast.imageUrl,
podcastTitle = podcast.title,
onUnfollowedClick = { onPodcastUnfollowed(podcast) },
lastEpisodeDateText = podcast.lastEpisodeDate?.let { lastUpdated(it) },
modifier = Modifier
.fillMaxSize()
.maskClip(MaterialTheme.shapes.large)
.clickable {
navigateToPodcastDetails(podcast)
},
)
}
}
}
@Composable
private fun FollowedPodcastCarouselItem(
podcastTitle: String,
podcastImageUrl: String,
modifier: Modifier = Modifier,
lastEpisodeDateText: String? = null,
onUnfollowedClick: () -> Unit,
) {
val gradient = Brush.verticalGradient(listOf(Color.Transparent, Color.Black))
Box(
modifier
.height(230.dp),
) {
PodcastImage(
podcastImageUrl = podcastImageUrl,
contentDescription = podcastTitle,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium),
)
ToggleFollowPodcastIconButton(
onClick = onUnfollowedClick,
isFollowed = true, /* All podcasts are followed in this feed */
modifier = Modifier.align(Alignment.TopStart),
)
Box(modifier = Modifier.matchParentSize().background(gradient))
if (lastEpisodeDateText != null) {
Text(
text = lastEpisodeDateText,
style = MaterialTheme.typography.bodySmall,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(12.dp)
.align(Alignment.BottomStart),
)
}
}
}
@Composable
private fun lastUpdated(updated: OffsetDateTime): String {
val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now())
val days = duration.toDays().toInt()
return when {
days > 28 -> stringResource(R.string.updated_longer)
days >= 7 -> {
val weeks = days / 7
quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks)
}
days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days)
else -> stringResource(R.string.updated_today)
}
}
@Preview
@Composable
private fun HomeAppBarPreview() {
JetcasterTheme {
HomeAppBar(
isExpanded = false,
)
}
}
private val CompactWindowSizeClass = WindowSizeClass.compute(360f, 780f)
@DevicePreviews
@Composable
private fun PreviewHome() {
JetcasterTheme {
HomeScreen(
windowSizeClass = CompactWindowSizeClass,
isLoading = true,
featuredPodcasts = PreviewPodcasts.toPersistentList(),
homeCategories = HomeCategory.entries,
selectedHomeCategory = HomeCategory.Discover,
filterableCategoriesModel = FilterableCategoriesModel(
categories = PreviewCategories,
selectedCategory = PreviewCategories.firstOrNull(),
),
podcastCategoryFilterResult = PodcastCategoryFilterResult(
topPodcasts = PreviewPodcasts,
episodes = PreviewPodcastEpisodes,
),
library = LibraryInfo(),
onHomeAction = {},
navigateToPodcastDetails = {},
navigateToPlayer = {},
)
}
}
@Composable
@Preview
private fun PreviewPodcastCard() {
JetcasterTheme {
FollowedPodcastCarouselItem(
modifier = Modifier.size(128.dp),
podcastTitle = "",
podcastImageUrl = "",
onUnfollowedClick = {},
)
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.ui.home
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
import com.example.jetcaster.core.domain.FilterableCategoriesUseCase
import com.example.jetcaster.core.domain.PodcastCategoryFilterUseCase
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.FilterableCategoriesModel
import com.example.jetcaster.core.model.LibraryInfo
import com.example.jetcaster.core.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.model.asDaoModel
import com.example.jetcaster.core.model.asExternalModel
import com.example.jetcaster.core.model.asPodcastToEpisodeInfo
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val podcastsRepository: PodcastsRepository,
private val podcastStore: PodcastStore,
private val episodeStore: EpisodeStore,
private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase,
private val filterableCategoriesUseCase: FilterableCategoriesUseCase,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
// Holds our currently selected podcast in the library
private val selectedLibraryPodcast = MutableStateFlow<PodcastInfo?>(null)
// Holds our currently selected home category
private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover)
// Holds the currently available home categories
private val homeCategories = MutableStateFlow(HomeCategory.entries)
// Holds our currently selected category
private val _selectedCategory = MutableStateFlow<CategoryInfo?>(null)
// Holds our view state which the UI collects via [state]
private val _state = MutableStateFlow(HomeScreenUiState())
// Holds the view state if the UI is refreshing for new data
private val refreshing = MutableStateFlow(false)
private val subscribedPodcasts = podcastStore.followedPodcastsSortedByLastEpisode(limit = 10)
.shareIn(viewModelScope, SharingStarted.WhileSubscribed())
val state: StateFlow<HomeScreenUiState>
get() = _state
init {
viewModelScope.launch {
// Combines the latest value from each of the flows, allowing us to generate a
// view state instance which only contains the latest values.
com.example.jetcaster.core.util.combine(
homeCategories,
selectedHomeCategory,
subscribedPodcasts,
refreshing,
_selectedCategory.flatMapLatest { selectedCategory ->
filterableCategoriesUseCase(selectedCategory)
},
_selectedCategory.flatMapLatest {
podcastCategoryFilterUseCase(it)
},
subscribedPodcasts.flatMapLatest { podcasts ->
episodeStore.episodesInPodcasts(
podcastUris = podcasts.map { it.podcast.uri },
limit = 20,
)
},
) {
homeCategories,
homeCategory,
podcasts,
refreshing,
filterableCategories,
podcastCategoryFilterResult,
libraryEpisodes,
->
_selectedCategory.value = filterableCategories.selectedCategory
// Override selected home category to show 'DISCOVER' if there are no
// featured podcasts
selectedHomeCategory.value =
if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory
HomeScreenUiState(
isLoading = refreshing,
homeCategories = homeCategories,
selectedHomeCategory = homeCategory,
featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(),
filterableCategoriesModel = filterableCategories,
podcastCategoryFilterResult = podcastCategoryFilterResult,
library = libraryEpisodes.asLibrary(),
)
}.catch { throwable ->
emit(
HomeScreenUiState(
isLoading = false,
errorMessage = throwable.message,
),
)
}.collect {
_state.value = it
}
}
refresh(force = false)
}
fun refresh(force: Boolean = true) {
viewModelScope.launch {
runCatching {
refreshing.value = true
podcastsRepository.updatePodcasts(force)
}
// TODO: look at result of runCatching and show any errors
refreshing.value = false
}
}
fun onHomeAction(action: HomeAction) {
when (action) {
is HomeAction.CategorySelected -> onCategorySelected(action.category)
is HomeAction.HomeCategorySelected -> onHomeCategorySelected(action.category)
is HomeAction.LibraryPodcastSelected -> onLibraryPodcastSelected(action.podcast)
is HomeAction.PodcastUnfollowed -> onPodcastUnfollowed(action.podcast)
is HomeAction.QueueEpisode -> onQueueEpisode(action.episode)
is HomeAction.RemoveEpisode -> deleteEpisode(action.episodeInfo)
is HomeAction.TogglePodcastFollowed -> onTogglePodcastFollowed(action.podcast)
}
}
private fun onCategorySelected(category: CategoryInfo) {
_selectedCategory.value = category
}
private fun onHomeCategorySelected(category: HomeCategory) {
selectedHomeCategory.value = category
}
private fun onPodcastUnfollowed(podcast: PodcastInfo) {
viewModelScope.launch {
podcastStore.unfollowPodcast(podcast.uri)
}
}
private fun onTogglePodcastFollowed(podcast: PodcastInfo) {
viewModelScope.launch {
podcastStore.togglePodcastFollowed(podcast.uri)
}
}
private fun onLibraryPodcastSelected(podcast: PodcastInfo?) {
selectedLibraryPodcast.value = podcast
}
private fun onQueueEpisode(episode: PlayerEpisode) {
episodePlayer.addToQueue(episode)
}
fun deleteEpisode(episode: EpisodeInfo) {
viewModelScope.launch {
episodeStore.deleteEpisode(episode.asDaoModel())
}
}
}
private fun List<EpisodeToPodcast>.asLibrary(): LibraryInfo = LibraryInfo(
episodes = this.map { it.asPodcastToEpisodeInfo() },
)
enum class HomeCategory {
Library,
Discover,
}
@Immutable
sealed interface HomeAction {
data class CategorySelected(val category: CategoryInfo) : HomeAction
data class HomeCategorySelected(val category: HomeCategory) : HomeAction
data class PodcastUnfollowed(val podcast: PodcastInfo) : HomeAction
data class TogglePodcastFollowed(val podcast: PodcastInfo) : HomeAction
data class LibraryPodcastSelected(val podcast: PodcastInfo?) : HomeAction
data class QueueEpisode(val episode: PlayerEpisode) : HomeAction
data class RemoveEpisode(val episodeInfo: EpisodeInfo) : HomeAction
}
@Immutable
data class HomeScreenUiState(
val isLoading: Boolean = true,
val errorMessage: String? = null,
val featuredPodcasts: PersistentList<PodcastInfo> = persistentListOf(),
val selectedHomeCategory: HomeCategory = HomeCategory.Discover,
val homeCategories: List<HomeCategory> = emptyList(),
val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(),
val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(),
val library: LibraryInfo = LibraryInfo(),
)
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalSharedTransitionApi::class)
package com.example.jetcaster.ui.home.category
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.jetcaster.core.domain.testing.PreviewEpisodes
import com.example.jetcaster.core.domain.testing.PreviewPodcasts
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.ui.LocalAnimatedVisibilityScope
import com.example.jetcaster.ui.LocalSharedTransitionScope
import com.example.jetcaster.ui.shared.EpisodeListItem
import com.example.jetcaster.ui.theme.JetcasterTheme
import com.example.jetcaster.util.ToggleFollowPodcastIconButton
import com.example.jetcaster.util.fullWidthItem
fun LazyGridScope.podcastCategory(
podcastCategoryFilterResult: PodcastCategoryFilterResult,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
onQueueEpisode: (PlayerEpisode) -> Unit,
removeFromQueue: (EpisodeInfo) -> Unit,
onTogglePodcastFollowed: (PodcastInfo) -> Unit,
) {
fullWidthItem {
CategoryPodcasts(
topPodcasts = podcastCategoryFilterResult.topPodcasts,
navigateToPodcastDetails = navigateToPodcastDetails,
onTogglePodcastFollowed = onTogglePodcastFollowed,
)
}
val episodes = podcastCategoryFilterResult.episodes
items(episodes, key = { it.episode.uri }) { item ->
val sharedTransitionScope = LocalSharedTransitionScope.current
?: throw IllegalStateException("No SharedElementScope found")
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
?: throw IllegalStateException("No SharedElementScope found")
with(sharedTransitionScope) {
EpisodeListItem(
episode = item.episode,
podcast = item.podcast,
onClick = navigateToPlayer,
onQueueEpisode = onQueueEpisode,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
imageModifier = Modifier.sharedElement(
sharedContentState = rememberSharedContentState(
key = item.episode.title,
),
animatedVisibilityScope = animatedVisibilityScope,
clipInOverlayDuringTransition = OverlayClip(MaterialTheme.shapes.medium),
),
removeFromQueue = removeFromQueue,
)
}
}
}
@Composable
private fun CategoryPodcasts(
topPodcasts: List<PodcastInfo>,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
onTogglePodcastFollowed: (PodcastInfo) -> Unit,
) {
CategoryPodcastRow(
podcasts = topPodcasts,
onTogglePodcastFollowed = onTogglePodcastFollowed,
navigateToPodcastDetails = navigateToPodcastDetails,
modifier = Modifier.fillMaxWidth(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryPodcastRow(
podcasts: List<PodcastInfo>,
onTogglePodcastFollowed: (PodcastInfo) -> Unit,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
) {
HorizontalUncontainedCarousel(
state = rememberCarouselState { podcasts.count() },
modifier = modifier.padding(start = 8.dp),
itemWidth = 128.dp,
itemSpacing = 4.dp,
) { i ->
val podcast = podcasts[i]
TopPodcastRowItem(
podcastTitle = podcast.title,
podcastImageUrl = podcast.imageUrl,
isFollowed = podcast.isSubscribed ?: false,
onToggleFollowClicked = { onTogglePodcastFollowed(podcast) },
modifier = Modifier
.width(128.dp)
.clickable {
navigateToPodcastDetails(podcast)
}
.maskClip(MaterialTheme.shapes.large),
)
}
}
@Composable
private fun TopPodcastRowItem(
podcastTitle: String,
podcastImageUrl: String,
isFollowed: Boolean,
modifier: Modifier = Modifier,
onToggleFollowClicked: () -> Unit,
) {
val gradient = Brush.verticalGradient(listOf(Color.Transparent, Color.Black))
Box(
modifier
.fillMaxWidth()
.height(128.dp)
.aspectRatio(1f)
.clip(MaterialTheme.shapes.large),
) {
PodcastImage(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium),
podcastImageUrl = podcastImageUrl,
contentDescription = podcastTitle,
)
ToggleFollowPodcastIconButton(
onClick = onToggleFollowClicked,
isFollowed = isFollowed,
modifier = Modifier.align(Alignment.TopStart),
)
Box(modifier = Modifier.matchParentSize().background(gradient))
Text(
text = podcastTitle,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = 16.dp),
)
}
}
@Preview
@Composable
fun PreviewEpisodeListItem() {
JetcasterTheme {
EpisodeListItem(
episode = PreviewEpisodes[0],
podcast = PreviewPodcasts[0],
onClick = { },
onQueueEpisode = { },
modifier = Modifier.fillMaxWidth(),
)
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.ui.home.discover
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.jetcaster.R
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.FilterableCategoriesModel
import com.example.jetcaster.core.model.PodcastCategoryFilterResult
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.theme.Keyline1
import com.example.jetcaster.ui.home.category.podcastCategory
import com.example.jetcaster.util.fullWidthItem
fun LazyGridScope.discoverItems(
filterableCategoriesModel: FilterableCategoriesModel,
podcastCategoryFilterResult: PodcastCategoryFilterResult,
navigateToPodcastDetails: (PodcastInfo) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
removeFromQueue: (EpisodeInfo) -> Unit,
onCategorySelected: (CategoryInfo) -> Unit,
onTogglePodcastFollowed: (PodcastInfo) -> Unit,
onQueueEpisode: (PlayerEpisode) -> Unit,
) {
if (filterableCategoriesModel.isEmpty) {
// TODO: empty state
return
}
fullWidthItem {
Spacer(Modifier.height(8.dp))
PodcastCategoryTabs(
filterableCategoriesModel = filterableCategoriesModel,
onCategorySelected = onCategorySelected,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
}
podcastCategory(
podcastCategoryFilterResult = podcastCategoryFilterResult,
navigateToPodcastDetails = navigateToPodcastDetails,
navigateToPlayer = navigateToPlayer,
onTogglePodcastFollowed = onTogglePodcastFollowed,
onQueueEpisode = onQueueEpisode,
removeFromQueue = removeFromQueue,
)
}
@Composable
private fun PodcastCategoryTabs(
filterableCategoriesModel: FilterableCategoriesModel,
onCategorySelected: (CategoryInfo) -> Unit,
modifier: Modifier = Modifier,
) {
val selectedIndex = filterableCategoriesModel.categories.indexOf(
filterableCategoriesModel.selectedCategory,
)
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = Keyline1),
verticalAlignment = Alignment.CenterVertically,
) {
itemsIndexed(
items = filterableCategoriesModel.categories,
key = { i, category -> category.id },
) { index, category ->
ChoiceChipContent(
text = category.name,
selected = index == selectedIndex,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp),
onClick = { onCategorySelected(category) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChoiceChipContent(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
FilterChip(
selected = selected,
onClick = onClick,
leadingIcon = {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(id = R.string.cd_selected_category),
modifier = Modifier.height(18.dp),
)
}
},
label = {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
},
colors = FilterChipDefaults.filterChipColors().copy(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
shape = MaterialTheme.shapes.large,
border = null,
modifier = modifier,
)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt
================================================
/*
* 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.home.library
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.jetcaster.R
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.LibraryInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.theme.Keyline1
import com.example.jetcaster.ui.shared.EpisodeListItem
import com.example.jetcaster.util.fullWidthItem
fun LazyGridScope.libraryItems(
library: LibraryInfo,
navigateToPlayer: (EpisodeInfo) -> Unit,
onQueueEpisode: (PlayerEpisode) -> Unit,
removeFromQueue: (EpisodeInfo) -> Unit,
) {
fullWidthItem {
Text(
text = stringResource(id = R.string.latest_episodes),
modifier = Modifier.padding(
start = Keyline1,
top = 16.dp,
),
style = MaterialTheme.typography.headlineMedium,
)
}
items(
library,
key = { it.episode.uri },
) { item ->
EpisodeListItem(
episode = item.episode,
podcast = item.podcast,
onClick = navigateToPlayer,
onQueueEpisode = onQueueEpisode,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
removeFromQueue = removeFromQueue,
)
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt
================================================
/*
* 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.
*/
@file:OptIn(ExperimentalSharedTransitionApi::class)
package com.example.jetcaster.ui.player
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.rounded.Forward10
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonColors
import androidx.compose.material3.ToggleButtonShapes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import androidx.window.layout.DisplayFeature
import androidx.window.layout.FoldingFeature
import com.example.jetcaster.R
import com.example.jetcaster.core.player.EpisodePlayerState
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.HtmlTextContainer
import com.example.jetcaster.designsystem.component.ImageBackgroundColorScrim
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.ui.LocalAnimatedVisibilityScope
import com.example.jetcaster.ui.LocalSharedTransitionScope
import com.example.jetcaster.ui.theme.JetcasterTheme
import com.example.jetcaster.ui.tooling.DevicePreviews
import com.example.jetcaster.util.isBookPosture
import com.example.jetcaster.util.isSeparatingPosture
import com.example.jetcaster.util.isTableTopPosture
import com.example.jetcaster.util.verticalGradientScrim
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
import com.google.accompanist.adaptive.TwoPane
import com.google.accompanist.adaptive.VerticalTwoPaneStrategy
import java.time.Duration
import kotlinx.coroutines.launch
/**
* Stateful version of the Podcast player
*/
@Composable
fun PlayerScreen(
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
onBackPress: () -> Unit,
viewModel: PlayerViewModel = hiltViewModel(),
) {
val uiState = viewModel.uiState
PlayerScreen(
uiState = uiState,
windowSizeClass = windowSizeClass,
displayFeatures = displayFeatures,
onBackPress = onBackPress,
onAddToQueue = viewModel::onAddToQueue,
onStop = viewModel::onStop,
playerControlActions = PlayerControlActions(
onPlayPress = viewModel::onPlay,
onPausePress = viewModel::onPause,
onAdvanceBy = viewModel::onAdvanceBy,
onRewindBy = viewModel::onRewindBy,
onSeekingStarted = viewModel::onSeekingStarted,
onSeekingFinished = viewModel::onSeekingFinished,
onNext = viewModel::onNext,
onPrevious = viewModel::onPrevious,
),
)
}
/**
* Stateless version of the Player screen
*/
@Composable
private fun PlayerScreen(
uiState: PlayerUiState,
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
onBackPress: () -> Unit,
onAddToQueue: () -> Unit,
onStop: () -> Unit,
playerControlActions: PlayerControlActions,
modifier: Modifier = Modifier,
) {
DisposableEffect(Unit) {
onDispose {
onStop()
}
}
val coroutineScope = rememberCoroutineScope()
val snackBarText = stringResource(id = R.string.episode_added_to_your_queue)
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
modifier = modifier,
) { contentPadding ->
if (uiState.episodePlayerState.currentEpisode != null) {
PlayerContentWithBackground(
uiState = uiState,
windowSizeClass = windowSizeClass,
displayFeatures = displayFeatures,
onBackPress = onBackPress,
onAddToQueue = {
coroutineScope.launch {
snackbarHostState.showSnackbar(snackBarText)
}
onAddToQueue()
},
playerControlActions = playerControlActions,
contentPadding = contentPadding,
)
} else {
FullScreenLoading()
}
}
}
@Composable
private fun PlayerBackground(episode: PlayerEpisode?, modifier: Modifier) {
ImageBackgroundColorScrim(
url = episode?.podcastImageUrl,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
modifier = modifier,
)
}
@Composable
fun PlayerContentWithBackground(
uiState: PlayerUiState,
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
onBackPress: () -> Unit,
onAddToQueue: () -> Unit,
playerControlActions: PlayerControlActions,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
PlayerBackground(
episode = uiState.episodePlayerState.currentEpisode,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
)
PlayerContent(
uiState = uiState,
windowSizeClass = windowSizeClass,
displayFeatures = displayFeatures,
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
playerControlActions = playerControlActions,
)
}
}
/**
* Wrapper around all actions for the player controls.
*/
data class PlayerControlActions(
val onPlayPress: () -> Unit,
val onPausePress: () -> Unit,
val onAdvanceBy: (Duration) -> Unit,
val onRewindBy: (Duration) -> Unit,
val onNext: () -> Unit,
val onPrevious: () -> Unit,
val onSeekingStarted: () -> Unit,
val onSeekingFinished: (newElapsed: Duration) -> Unit,
)
@Composable
fun PlayerContent(
uiState: PlayerUiState,
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
onBackPress: () -> Unit,
onAddToQueue: () -> Unit,
playerControlActions: PlayerControlActions,
modifier: Modifier = Modifier,
) {
val foldingFeature = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
// Use a two pane layout if there is a fold impacting layout (meaning it is separating
// or non-flat) or if we have a large enough width to show both.
if (
windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED ||
isBookPosture(foldingFeature) ||
isTableTopPosture(foldingFeature) ||
isSeparatingPosture(foldingFeature)
) {
// Determine if we are going to be using a vertical strategy (as if laying out
// both sides in a column). We want to do so if we are in a tabletop posture,
// or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy.
val usingVerticalStrategy =
isTableTopPosture(foldingFeature) ||
(
isSeparatingPosture(foldingFeature) &&
foldingFeature.orientation ==
FoldingFeature.Orientation.HORIZONTAL
)
if (usingVerticalStrategy) {
TwoPane(
first = {
PlayerContentTableTopTop(
uiState = uiState,
)
},
second = {
PlayerContentTableTopBottom(
uiState = uiState,
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
playerControlActions = playerControlActions,
)
},
strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f),
displayFeatures = displayFeatures,
modifier = modifier,
)
} else {
Column(
modifier = modifier
.fillMaxSize()
.verticalGradientScrim(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f),
startYPercentage = 1f,
endYPercentage = 0f,
)
.systemBarsPadding()
.padding(horizontal = 8.dp),
) {
TopAppBar(
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
)
TwoPane(
first = {
PlayerContentBookStart(uiState = uiState)
},
second = {
PlayerContentBookEnd(
uiState = uiState,
playerControlActions = playerControlActions,
)
},
strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f),
displayFeatures = displayFeatures,
)
}
}
} else {
PlayerContentRegular(
uiState = uiState,
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
playerControlActions = playerControlActions,
modifier = modifier,
)
}
}
/**
* The UI for the top pane of a tabletop layout.
*/
@Composable
private fun PlayerContentRegular(
uiState: PlayerUiState,
onBackPress: () -> Unit,
onAddToQueue: () -> Unit,
playerControlActions: PlayerControlActions,
modifier: Modifier = Modifier,
) {
val playerEpisode = uiState.episodePlayerState
val currentEpisode = playerEpisode.currentEpisode ?: return
val sharedTransitionScope = LocalSharedTransitionScope.current
?: throw IllegalStateException("No SharedElementScope found")
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
?: throw IllegalStateException("No SharedElementScope found")
Column(
modifier = modifier
.fillMaxSize()
.verticalGradientScrim(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f),
startYPercentage = 1f,
endYPercentage = 0f,
)
.systemBarsPadding()
.padding(horizontal = 8.dp),
) {
TopAppBar(
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp),
) {
Spacer(modifier = Modifier.weight(1f))
with(sharedTransitionScope) {
with(animatedVisibilityScope) {
PlayerImage(
podcastImageUrl = currentEpisode.podcastImageUrl,
modifier = Modifier
.weight(10f)
.animateEnterExit(
enter = fadeIn(spring(stiffness = Spring.StiffnessLow)),
exit = fadeOut(),
),
imageModifier = Modifier.sharedElement(
sharedContentState = rememberSharedContentState(
key = currentEpisode.title,
),
animatedVisibilityScope = animatedVisibilityScope,
clipInOverlayDuringTransition =
OverlayClip(MaterialTheme.shapes.medium),
),
)
}
Spacer(modifier = Modifier.height(32.dp))
PodcastDescription(currentEpisode.title, currentEpisode.podcastName)
Spacer(modifier = Modifier.height(32.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(10f),
) {
PlayerSlider(
timeElapsed = playerEpisode.timeElapsed,
episodeDuration = currentEpisode.duration,
onSeekingStarted = playerControlActions.onSeekingStarted,
onSeekingFinished = playerControlActions.onSeekingFinished,
)
PlayerButtons(
hasNext = playerEpisode.queue.isNotEmpty(),
isPlaying = playerEpisode.isPlaying,
onPlayPress = playerControlActions.onPlayPress,
onPausePress = playerControlActions.onPausePress,
onAdvanceBy = playerControlActions.onAdvanceBy,
onRewindBy = playerControlActions.onRewindBy,
onNext = playerControlActions.onNext,
onPrevious = playerControlActions.onPrevious,
Modifier.padding(vertical = 8.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
/**
* The UI for the top pane of a tabletop layout.
*/
@Composable
private fun PlayerContentTableTopTop(uiState: PlayerUiState, modifier: Modifier = Modifier) {
// Content for the top part of the screen
val episode = uiState.episodePlayerState.currentEpisode ?: return
Column(
modifier = modifier
.fillMaxWidth()
.verticalGradientScrim(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f),
startYPercentage = 1f,
endYPercentage = 0f,
)
.windowInsetsPadding(
WindowInsets.systemBars.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
),
)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayerImage(episode.podcastImageUrl)
}
}
/**
* The UI for the bottom pane of a tabletop layout.
*/
@Composable
private fun PlayerContentTableTopBottom(
uiState: PlayerUiState,
onBackPress: () -> Unit,
onAddToQueue: () -> Unit,
playerControlActions: PlayerControlActions,
modifier: Modifier = Modifier,
) {
val episodePlayerState = uiState.episodePlayerState
val episode = uiState.episodePlayerState.currentEpisode ?: return
// Content for the table part of the screen
Column(
modifier = modifier
.windowInsetsPadding(
WindowInsets.systemBars.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(horizontal = 32.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TopAppBar(
onBackPress = onBackPress,
onAddToQueue = onAddToQueue,
)
PodcastDescription(
title = episode.title,
podcastName = episode.podcastName,
)
Spacer(modifier = Modifier.weight(0.5f))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(10f),
) {
PlayerButtons(
hasNext = episodePlayerState.queue.isNotEmpty(),
isPlaying = episodePlayerState.isPlaying,
onPlayPress = playerControlActions.onPlayPress,
onPausePress = playerControlActions.onPausePress,
onAdvanceBy = playerControlActions.onAdvanceBy,
onRewindBy = playerControlActions.onRewindBy,
onNext = playerControlActions.onNext,
onPrevious = playerControlActions.onPrevious,
modifier = Modifier.padding(top = 8.dp),
)
PlayerSlider(
timeElapsed = episodePlayerState.timeElapsed,
episodeDuration = episode.duration,
onSeekingStarted = playerControlActions.onSeekingStarted,
onSeekingFinished = playerControlActions.onSeekingFinished,
)
}
}
}
/**
* The UI for the start pane of a book layout.
*/
@Composable
private fun PlayerContentBookStart(uiState: PlayerUiState, modifier: Modifier = Modifier) {
val episode = uiState.episodePlayerState.currentEpisode ?: return
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(
vertical = 40.dp,
horizontal = 16.dp,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PodcastInformation(
title = episode.title,
name = episode.podcastName,
summary = episode.summary,
)
}
}
/**
* The UI for the end pane of a book layout.
*/
@Composable
private fun PlayerContentBookEnd(uiState: PlayerUiState, playerControlActions: PlayerControlActions, modifier: Modifier = Modifier) {
val episodePlayerState = uiState.episodePlayerState
val episode = episodePlayerState.currentEpisode ?: return
Column(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround,
) {
PlayerImage(
podcastImageUrl = episode.podcastImageUrl,
modifier = Modifier
.padding(vertical = 16.dp)
.weight(1f),
)
PlayerSlider(
timeElapsed = episodePlayerState.timeElapsed,
episodeDuration = episode.duration,
onSeekingStarted = playerControlActions.onSeekingStarted,
onSeekingFinished = playerControlActions.onSeekingFinished,
)
PlayerButtons(
hasNext = episodePlayerState.queue.isNotEmpty(),
isPlaying = episodePlayerState.isPlaying,
onPlayPress = playerControlActions.onPlayPress,
onPausePress = playerControlActions.onPausePress,
onAdvanceBy = playerControlActions.onAdvanceBy,
onRewindBy = playerControlActions.onRewindBy,
onNext = playerControlActions.onNext,
onPrevious = playerControlActions.onPrevious,
Modifier.padding(vertical = 8.dp),
)
}
}
@Composable
private fun TopAppBar(onBackPress: () -> Unit, onAddToQueue: () -> Unit) {
Row(Modifier.fillMaxWidth()) {
IconButton(onClick = onBackPress) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onAddToQueue) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = stringResource(R.string.cd_add),
)
}
IconButton(onClick = { /* TODO */ }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_more),
)
}
}
}
@Composable
private fun PlayerImage(podcastImageUrl: String, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier) {
PodcastImage(
podcastImageUrl = podcastImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.sizeIn(maxWidth = 500.dp, maxHeight = 500.dp)
.aspectRatio(1f)
.clip(MaterialTheme.shapes.medium),
imageModifier = imageModifier,
)
}
@Composable
private fun PodcastDescription(title: String, podcastName: String) {
Text(
text = title,
style = MaterialTheme.typography.displayLarge,
maxLines = 2,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.basicMarquee(),
)
Text(
text = podcastName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
@Composable
private fun PodcastInformation(
title: String,
name: String,
summary: String,
modifier: Modifier = Modifier,
titleTextStyle: TextStyle = MaterialTheme.typography.headlineLarge,
nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall,
) {
Column(
modifier = modifier.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = name,
style = nameTextStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = title,
style = titleTextStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
HtmlTextContainer(text = summary) {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current,
)
}
}
}
fun Duration.formatString(): String {
val minutes = this.toMinutes().toString().padStart(2, '0')
val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0')
return "$minutes:$secondsLeft"
}
@Composable
private fun PlayerSlider(
timeElapsed: Duration,
episodeDuration: Duration?,
onSeekingStarted: () -> Unit,
onSeekingFinished: (newElapsed: Duration) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
var sliderValue by remember(timeElapsed) { mutableStateOf(timeElapsed) }
val maxRange = (episodeDuration?.toSeconds() ?: 0).toFloat()
Row(Modifier.fillMaxWidth()) {
Text(
text = "${sliderValue.formatString()} • ${episodeDuration?.formatString()}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Slider(
value = sliderValue.seconds.toFloat(),
valueRange = 0f..maxRange,
onValueChange = {
onSeekingStarted()
sliderValue = Duration.ofSeconds(it.toLong())
},
onValueChangeFinished = { onSeekingFinished(sliderValue) },
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PlayerButtons(
hasNext: Boolean,
isPlaying: Boolean,
onPlayPress: () -> Unit,
onPausePress: () -> Unit,
onAdvanceBy: (Duration) -> Unit,
onRewindBy: (Duration) -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
ToggleButton(
checked = isPlaying,
onCheckedChange = {
if (isPlaying) {
onPausePress()
} else {
onPlayPress()
}
},
colors = ToggleButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.primary,
disabledContentColor = MaterialTheme.colorScheme.onPrimary,
checkedContainerColor = MaterialTheme.colorScheme.tertiary,
checkedContentColor = MaterialTheme.colorScheme.onTertiary,
),
shapes = ToggleButtonShapes(
shape = RoundedCornerShape(60.dp),
pressedShape = RoundedCornerShape(if (isPlaying) 60.dp else 30.dp),
checkedShape = RoundedCornerShape(30.dp),
),
modifier = Modifier
.width(186.dp)
.height(136.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
ButtonGroup(
overflowIndicator = {},
modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val skipButtonsModifier = Modifier
.width(56.dp)
.height(68.dp)
val rewindFastForwardButtonsModifier = Modifier
.size(68.dp)
val interactionSources = List(4) { MutableInteractionSource() }
customItem(
buttonGroupContent = {
IconButton(
onClick = onPrevious,
modifier = skipButtonsModifier.animateWidth(interactionSource = interactionSources[0]),
shape = RoundedCornerShape(50.dp),
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
disabledContentColor = MaterialTheme.colorScheme.onPrimary,
),
interactionSource = interactionSources[0],
enabled = isPlaying,
) {
Icon(
imageVector = Icons.Rounded.SkipPrevious,
contentDescription = null,
)
}
},
menuContent = { },
)
customItem(
buttonGroupContent = {
IconButton(
onClick = { onRewindBy(Duration.ofSeconds(10)) },
modifier = rewindFastForwardButtonsModifier.animateWidth(interactionSource = interactionSources[1]),
shape = RoundedCornerShape(15.dp),
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary,
),
interactionSource = interactionSources[1],
enabled = isPlaying,
) {
Icon(
imageVector = Icons.Rounded.Replay10,
contentDescription = null,
)
}
},
menuContent = { },
)
customItem(
buttonGroupContent = {
IconButton(
onClick = { onAdvanceBy(Duration.ofSeconds(10)) },
modifier = rewindFastForwardButtonsModifier.animateWidth(interactionSource = interactionSources[2]),
shape = RoundedCornerShape(15.dp),
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary,
),
interactionSource = interactionSources[2],
enabled = isPlaying,
) {
Icon(
imageVector = Icons.Rounded.Forward10,
contentDescription = null,
)
}
},
menuContent = { },
)
customItem(
buttonGroupContent = {
IconButton(
onClick = onNext,
modifier = skipButtonsModifier.animateWidth(interactionSource = interactionSources[3]),
shape = RoundedCornerShape(50.dp),
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
disabledContentColor = MaterialTheme.colorScheme.onSurface,
),
interactionSource = interactionSources[3],
enabled = hasNext,
) {
Icon(
imageVector = Icons.Rounded.SkipNext,
contentDescription = null,
)
}
},
menuContent = { },
)
}
}
}
/**
* Full screen circular progress indicator
*/
@Composable
private fun FullScreenLoading(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center),
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
fun TopAppBarPreview() {
JetcasterTheme {
TopAppBar(
onBackPress = {},
onAddToQueue = {},
)
}
}
@Preview
@Composable
fun PlayerButtonsPreview() {
JetcasterTheme {
PlayerButtons(
hasNext = false,
isPlaying = true,
onPlayPress = {},
onPausePress = {},
onAdvanceBy = {},
onRewindBy = {},
onNext = {},
onPrevious = {},
)
}
}
@DevicePreviews
@Composable
fun PlayerScreenPreview() {
JetcasterTheme {
BoxWithConstraints {
PlayerScreen(
PlayerUiState(
episodePlayerState = EpisodePlayerState(
currentEpisode = PlayerEpisode(
title = "Title",
duration = Duration.ofHours(2),
podcastName = "Podcast",
),
isPlaying = false,
queue = listOf(
PlayerEpisode(),
PlayerEpisode(),
PlayerEpisode(),
),
),
),
displayFeatures = emptyList(),
windowSizeClass = WindowSizeClass.compute(maxWidth.value, maxHeight.value),
onBackPress = { },
onAddToQueue = {},
onStop = {},
playerControlActions = PlayerControlActions(
onPlayPress = {},
onPausePress = {},
onAdvanceBy = {},
onRewindBy = {},
onSeekingStarted = {},
onSeekingFinished = {},
onNext = {},
onPrevious = {},
),
)
}
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt
================================================
/*
* 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.ui.player
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.EpisodePlayerState
import com.example.jetcaster.core.player.model.toPlayerEpisode
import com.example.jetcaster.ui.Screen
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Duration
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
data class PlayerUiState(val episodePlayerState: EpisodePlayerState = EpisodePlayerState())
/**
* ViewModel that handles the business logic and screen state of the Player screen
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class PlayerViewModel @Inject constructor(
episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// episodeUri should always be present in the PlayerViewModel.
// If that's not the case, fail crashing the app!
private val episodeUri: String =
Uri.decode(savedStateHandle.get<String>(Screen.ARG_EPISODE_URI)!!)
var uiState by mutableStateOf(PlayerUiState())
private set
init {
viewModelScope.launch {
episodeStore.episodeAndPodcastWithUri(episodeUri).flatMapConcat {
episodePlayer.currentEpisode = it.toPlayerEpisode()
episodePlayer.playerState
}.map {
PlayerUiState(episodePlayerState = it)
}.collect {
uiState = it
}
}
}
fun onPlay() {
episodePlayer.play()
}
fun onPause() {
episodePlayer.pause()
}
fun onStop() {
episodePlayer.stop()
}
fun onPrevious() {
episodePlayer.previous()
}
fun onNext() {
episodePlayer.next()
}
fun onAdvanceBy(duration: Duration) {
episodePlayer.advanceBy(duration)
}
fun onRewindBy(duration: Duration) {
episodePlayer.rewindBy(duration)
}
fun onSeekingStarted() {
episodePlayer.onSeekingStarted()
}
fun onSeekingFinished(duration: Duration) {
episodePlayer.onSeekingFinished(duration)
}
fun onAddToQueue() {
uiState.episodePlayerState.currentEpisode?.let {
episodePlayer.addToQueue(it)
}
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt
================================================
/*
* 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.podcast
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.EaseOutExpo
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonColors
import androidx.compose.material3.ToggleButtonShapes
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.jetcaster.R
import com.example.jetcaster.core.domain.testing.PreviewEpisodes
import com.example.jetcaster.core.domain.testing.PreviewPodcasts
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.designsystem.theme.Keyline1
import com.example.jetcaster.ui.shared.EpisodeListItem
import com.example.jetcaster.ui.shared.Loading
import com.example.jetcaster.ui.tooling.DevicePreviews
import com.example.jetcaster.util.fullWidthItem
import kotlinx.coroutines.launch
@Composable
fun PodcastDetailsScreen(
viewModel: PodcastDetailsViewModel,
navigateToPlayer: (EpisodeInfo) -> Unit,
navigateBack: () -> Unit,
showBackButton: Boolean,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is PodcastUiState.Loading -> {
PodcastDetailsLoadingScreen(
modifier = Modifier.fillMaxSize(),
)
}
is PodcastUiState.Ready -> {
PodcastDetailsScreen(
podcast = s.podcast,
episodes = s.episodes,
toggleSubscribe = viewModel::toggleSusbcribe,
onQueueEpisode = viewModel::onQueueEpisode,
removeFromQueue = viewModel::deleteEpisode,
navigateToPlayer = navigateToPlayer,
navigateBack = navigateBack,
showBackButton = showBackButton,
modifier = modifier,
)
}
}
}
@Composable
private fun PodcastDetailsLoadingScreen(modifier: Modifier = Modifier) {
Loading(modifier = modifier)
}
@Composable
fun PodcastDetailsScreen(
podcast: PodcastInfo,
episodes: List<EpisodeInfo>,
toggleSubscribe: (PodcastInfo) -> Unit,
onQueueEpisode: (PlayerEpisode) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
navigateBack: () -> Unit,
showBackButton: Boolean,
modifier: Modifier = Modifier,
removeFromQueue: (EpisodeInfo) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val snackBarText = stringResource(id = R.string.episode_added_to_your_queue)
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
if (showBackButton) {
PodcastDetailsTopAppBar(
navigateBack = navigateBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { contentPadding ->
PodcastDetailsContent(
podcast = podcast,
episodes = episodes,
toggleSubscribe = toggleSubscribe,
removeFromQueue = removeFromQueue,
onQueueEpisode = {
coroutineScope.launch {
snackbarHostState.showSnackbar(snackBarText)
}
onQueueEpisode(it)
},
navigateToPlayer = navigateToPlayer,
modifier = Modifier.padding(contentPadding),
)
}
}
@Composable
fun PodcastDetailsContent(
podcast: PodcastInfo,
episodes: List<EpisodeInfo>,
removeFromQueue: (EpisodeInfo) -> Unit,
toggleSubscribe: (PodcastInfo) -> Unit,
onQueueEpisode: (PlayerEpisode) -> Unit,
navigateToPlayer: (EpisodeInfo) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(362.dp),
modifier.fillMaxSize(),
) {
fullWidthItem {
PodcastDetailsHeaderItem(
podcast = podcast,
toggleSubscribe = toggleSubscribe,
modifier = Modifier.fillMaxWidth(),
)
}
items(episodes, key = { it.uri }) { episode ->
EpisodeListItem(
episode = episode,
podcast = podcast,
onClick = navigateToPlayer,
removeFromQueue = removeFromQueue,
onQueueEpisode = onQueueEpisode,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
showPodcastImage = false,
showSummary = true,
)
}
}
}
@Composable
fun PodcastDetailsHeaderItem(podcast: PodcastInfo, toggleSubscribe: (PodcastInfo) -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(Keyline1),
) {
Column {
PodcastImage(
modifier = Modifier
.size(280.dp)
.clip(MaterialTheme.shapes.large)
.align(Alignment.CenterHorizontally),
podcastImageUrl = podcast.imageUrl,
contentDescription = podcast.title,
)
Text(
text = podcast.title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.padding(top = 16.dp),
)
PodcastDetailsDescription(
podcast = podcast,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
)
PodcastDetailsHeaderItemButtons(
isSubscribed = podcast.isSubscribed ?: false,
onClick = {
toggleSubscribe(podcast)
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
fun PodcastDetailsDescription(podcast: PodcastInfo, modifier: Modifier) {
var isExpanded by remember { mutableStateOf(false) }
var showSeeMore by remember { mutableStateOf(false) }
Box(
modifier = modifier.clickable { isExpanded = !isExpanded },
) {
Text(
text = podcast.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (isExpanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
onTextLayout = { result ->
showSeeMore = result.hasVisualOverflow
},
modifier = Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 200,
easing = EaseOutExpo,
),
),
)
if (showSeeMore) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.background(MaterialTheme.colorScheme.surface),
) {
Text(
text = stringResource(id = R.string.see_more),
style = MaterialTheme.typography.bodyMedium.copy(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PodcastDetailsHeaderItemButtons(isSubscribed: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
var isNotificationOn by remember { mutableStateOf(false) }
val interactionSource1 = remember { MutableInteractionSource() }
val interactionSource2 = remember { MutableInteractionSource() }
ButtonGroup(
overflowIndicator = {},
modifier = modifier,
) {
customItem(
buttonGroupContent = {
ToggleButton(
checked = isSubscribed,
onCheckedChange = { onClick() },
colors = ToggleButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
disabledContainerColor = MaterialTheme.colorScheme.inverseSurface,
disabledContentColor = MaterialTheme.colorScheme.surfaceVariant,
checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
checkedContentColor = MaterialTheme.colorScheme.secondary,
),
shapes = ToggleButtonShapes(
shape = RoundedCornerShape(15.dp),
pressedShape = RoundedCornerShape(if (isSubscribed) 15.dp else 60.dp),
checkedShape = RoundedCornerShape(60.dp),
),
modifier = Modifier
.width(76.dp)
.height(56.dp)
.animateWidth(interactionSource = interactionSource1)
.semantics(mergeDescendants = true) { },
interactionSource = interactionSource1,
) {
Icon(
imageVector = if (isSubscribed)
Icons.Default.Check
else
Icons.Default.Add,
contentDescription = null,
)
}
},
menuContent = { },
)
customItem(
buttonGroupContent = {
ToggleButton(
checked = isNotificationOn,
onCheckedChange = { isNotificationOn = !isNotificationOn },
colors = ToggleButtonColors(
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.inverseSurface,
disabledContentColor = MaterialTheme.colorScheme.surfaceVariant,
checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
checkedContentColor = MaterialTheme.colorScheme.secondary,
),
shapes = ToggleButtonShapes(
shape = RoundedCornerShape(100.dp),
pressedShape = RoundedCornerShape(if (isNotificationOn) 100.dp else 20.dp),
checkedShape = RoundedCornerShape(20.dp),
),
interactionSource = interactionSource2,
modifier = Modifier
.size(56.dp)
.animateWidth(interactionSource = interactionSource2),
) {
Icon(
imageVector = if (isNotificationOn) {
Icons.Default.NotificationsActive
} else {
Icons.Default.NotificationsNone
},
contentDescription = stringResource(R.string.cd_more),
)
}
},
menuContent = {},
)
customItem(
buttonGroupContent = { Spacer(modifier.weight(1f)) },
menuContent = { },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PodcastDetailsTopAppBar(navigateBack: () -> Unit, modifier: Modifier = Modifier) {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.cd_back),
)
}
},
modifier = modifier,
)
}
@Preview
@Composable
fun PodcastDetailsHeaderItemPreview() {
PodcastDetailsHeaderItem(
podcast = PreviewPodcasts[0],
toggleSubscribe = { },
)
}
@DevicePreviews
@Composable
fun PodcastDetailsScreenPreview() {
PodcastDetailsScreen(
podcast = PreviewPodcasts[0],
episodes = PreviewEpisodes,
toggleSubscribe = { },
onQueueEpisode = { },
navigateToPlayer = { },
navigateBack = { },
showBackButton = true,
)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt
================================================
/*
* 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.podcast
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.model.asDaoModel
import com.example.jetcaster.core.model.asExternalModel
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed interface PodcastUiState {
data object Loading : PodcastUiState
data class Ready(val podcast: PodcastInfo, val episodes: List<EpisodeInfo>) : PodcastUiState
}
/**
* ViewModel that handles the business logic and screen state of the Podcast details screen.
*/
@HiltViewModel(assistedFactory = PodcastDetailsViewModel.Factory::class)
class PodcastDetailsViewModel @AssistedInject constructor(
private val episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
private val podcastStore: PodcastStore,
@Assisted private val podcastUri: String,
) : ViewModel() {
private val decodedPodcastUri = Uri.decode(podcastUri)
val state: StateFlow<PodcastUiState> =
combine(
podcastStore.podcastWithExtraInfo(decodedPodcastUri),
episodeStore.episodesInPodcast(decodedPodcastUri),
) { podcast, episodeToPodcasts ->
val episodes = episodeToPodcasts.map { it.episode.asExternalModel() }
PodcastUiState.Ready(
podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed),
episodes = episodes,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PodcastUiState.Loading,
)
fun toggleSusbcribe(podcast: PodcastInfo) {
viewModelScope.launch {
podcastStore.togglePodcastFollowed(podcast.uri)
}
}
fun onQueueEpisode(playerEpisode: PlayerEpisode) {
episodePlayer.addToQueue(playerEpisode)
}
fun deleteEpisode(episodeInfo: EpisodeInfo) {
viewModelScope.launch {
episodeStore.deleteEpisode(episodeInfo.asDaoModel())
}
}
@AssistedFactory
interface Factory {
fun create(podcastUri: String): PodcastDetailsViewModel
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt
================================================
/*
* 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.shared
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.rounded.PlayCircleFilled
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.jetcaster.R
import com.example.jetcaster.core.domain.testing.PreviewEpisodes
import com.example.jetcaster.core.domain.testing.PreviewPodcasts
import com.example.jetcaster.core.model.EpisodeInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.HtmlTextContainer
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.ui.theme.JetcasterTheme
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun EpisodeListItem(
episode: EpisodeInfo,
podcast: PodcastInfo,
onClick: (EpisodeInfo) -> Unit,
removeFromQueue: (EpisodeInfo) -> Unit = {},
onQueueEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
showPodcastImage: Boolean = true,
showSummary: Boolean = false,
) {
val dismissState = rememberSwipeToDismissBoxState()
SwipeToDismissBox(
modifier = modifier,
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 40.dp),
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.align(Alignment.CenterEnd),
)
}
},
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
onClick = { onClick(episode) },
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Top Part
EpisodeListItemHeader(
episode = episode,
podcast = podcast,
showPodcastImage = showPodcastImage,
showSummary = showSummary,
modifier = Modifier.padding(bottom = 8.dp),
imageModifier = imageModifier,
)
// Bottom Part
EpisodeListItemFooter(
episode = episode,
podcast = podcast,
onQueueEpisode = onQueueEpisode,
)
}
}
}
when (dismissState.currentValue) {
SwipeToDismissBoxValue.EndToStart -> {
removeFromQueue(episode)
}
SwipeToDismissBoxValue.StartToEnd -> {
}
SwipeToDismissBoxValue.Settled -> {
}
}
}
}
@Composable
private fun EpisodeListItemFooter(
episode: EpisodeInfo,
podcast: PodcastInfo,
onQueueEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Image(
imageVector = Icons.Rounded.PlayCircleFilled,
contentDescription = stringResource(R.string.cd_play),
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 24.dp),
) { /* TODO */ }
.size(48.dp)
.padding(6.dp)
.semantics { role = Role.Button },
)
val duration = episode.duration
Text(
text = when {
duration != null -> {
// If we have the duration, we combine the date/duration via a
// formatted string
stringResource(
R.string.episode_date_duration,
MediumDateFormatter.format(episode.published),
duration.toMinutes().toInt(),
)
}
// Otherwise we just use the date
else -> MediumDateFormatter.format(episode.published)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f),
)
IconButton(
onClick = {
onQueueEpisode(
PlayerEpisode(
podcastInfo = podcast,
episodeInfo = episode,
),
)
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = stringResource(R.string.cd_add),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = { /* TODO */ },
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_more),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun EpisodeListItemHeader(
episode: EpisodeInfo,
podcast: PodcastInfo,
showPodcastImage: Boolean,
showSummary: Boolean,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Column(
modifier =
Modifier
.weight(1f)
.padding(end = 16.dp),
) {
Text(
text = episode.title,
maxLines = 2,
minLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
if (showSummary) {
HtmlTextContainer(text = episode.summary) {
Text(
text = it,
maxLines = 2,
minLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
}
} else {
Text(
text = podcast.title,
maxLines = 2,
minLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
}
}
if (showPodcastImage) {
EpisodeListItemImage(
podcast = podcast,
modifier = Modifier
.size(56.dp)
.clip(MaterialTheme.shapes.medium),
imageModifier = imageModifier,
)
}
}
}
@Composable
private fun EpisodeListItemImage(podcast: PodcastInfo, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier) {
PodcastImage(
podcastImageUrl = podcast.imageUrl,
contentDescription = null,
modifier = modifier,
imageModifier = imageModifier,
)
}
@Preview(
name = "Light Mode",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_NO,
)
@Preview(
name = "Dark Mode",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun EpisodeListItemPreview() {
JetcasterTheme {
EpisodeListItem(
episode = PreviewEpisodes[0],
podcast = PreviewPodcasts[0],
onClick = {},
onQueueEpisode = {},
showSummary = true,
)
}
}
private val MediumDateFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt
================================================
/*
* 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.shared
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun Loading(modifier: Modifier = Modifier) {
Surface(modifier = modifier) {
Box(
modifier = Modifier.fillMaxSize(),
) {
CircularProgressIndicator(
Modifier.align(Alignment.Center),
)
}
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.ui.theme
/**
* This is the minimum amount of calculated contrast for a color to be used on top of the
* surface color. These values are defined within the WCAG AA guidelines, and we use a value of
* 3:1 which is the minimum for user-interface components.
*/
const val MinContrastOfPrimaryVsSurface = 3f
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.example.jetcaster.designsystem.theme.JetcasterShapes
import com.example.jetcaster.designsystem.theme.JetcasterTypography
import com.example.jetcaster.designsystem.theme.backgroundDark
import com.example.jetcaster.designsystem.theme.backgroundDarkHighContrast
import com.example.jetcaster.designsystem.theme.backgroundDarkMediumContrast
import com.example.jetcaster.designsystem.theme.backgroundLight
import com.example.jetcaster.designsystem.theme.backgroundLightHighContrast
import com.example.jetcaster.designsystem.theme.backgroundLightMediumContrast
import com.example.jetcaster.designsystem.theme.errorContainerDark
import com.example.jetcaster.designsystem.theme.errorContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.errorContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.errorContainerLight
import com.example.jetcaster.designsystem.theme.errorContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.errorContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.errorDark
import com.example.jetcaster.designsystem.theme.errorDarkHighContrast
import com.example.jetcaster.designsystem.theme.errorDarkMediumContrast
import com.example.jetcaster.designsystem.theme.errorLight
import com.example.jetcaster.designsystem.theme.errorLightHighContrast
import com.example.jetcaster.designsystem.theme.errorLightMediumContrast
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkHighContrast
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkMediumContrast
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightHighContrast
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightMediumContrast
import com.example.jetcaster.designsystem.theme.inversePrimaryDark
import com.example.jetcaster.designsystem.theme.inversePrimaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.inversePrimaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.inversePrimaryLight
import com.example.jetcaster.designsystem.theme.inversePrimaryLightHighContrast
import com.example.jetcaster.designsystem.theme.inversePrimaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.inverseSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkHighContrast
import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkMediumContrast
import com.example.jetcaster.designsystem.theme.inverseSurfaceLight
import com.example.jetcaster.designsystem.theme.inverseSurfaceLightHighContrast
import com.example.jetcaster.designsystem.theme.inverseSurfaceLightMediumContrast
import com.example.jetcaster.designsystem.theme.onBackgroundDark
import com.example.jetcaster.designsystem.theme.onBackgroundDarkHighContrast
import com.example.jetcaster.designsystem.theme.onBackgroundDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onBackgroundLight
import com.example.jetcaster.designsystem.theme.onBackgroundLightHighContrast
import com.example.jetcaster.designsystem.theme.onBackgroundLightMediumContrast
import com.example.jetcaster.designsystem.theme.onErrorContainerDark
import com.example.jetcaster.designsystem.theme.onErrorContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.onErrorContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onErrorContainerLight
import com.example.jetcaster.designsystem.theme.onErrorContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.onErrorContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.onErrorDark
import com.example.jetcaster.designsystem.theme.onErrorDarkHighContrast
import com.example.jetcaster.designsystem.theme.onErrorDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onErrorLight
import com.example.jetcaster.designsystem.theme.onErrorLightHighContrast
import com.example.jetcaster.designsystem.theme.onErrorLightMediumContrast
import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark
import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight
import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.onPrimaryDark
import com.example.jetcaster.designsystem.theme.onPrimaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.onPrimaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onPrimaryLight
import com.example.jetcaster.designsystem.theme.onPrimaryLightHighContrast
import com.example.jetcaster.designsystem.theme.onPrimaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark
import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight
import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.onSecondaryDark
import com.example.jetcaster.designsystem.theme.onSecondaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.onSecondaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onSecondaryLight
import com.example.jetcaster.designsystem.theme.onSecondaryLightHighContrast
import com.example.jetcaster.designsystem.theme.onSecondaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.onSurfaceDark
import com.example.jetcaster.designsystem.theme.onSurfaceDarkHighContrast
import com.example.jetcaster.designsystem.theme.onSurfaceDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onSurfaceLight
import com.example.jetcaster.designsystem.theme.onSurfaceLightHighContrast
import com.example.jetcaster.designsystem.theme.onSurfaceLightMediumContrast
import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark
import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkHighContrast
import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight
import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightHighContrast
import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightMediumContrast
import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark
import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight
import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.onTertiaryDark
import com.example.jetcaster.designsystem.theme.onTertiaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.onTertiaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.onTertiaryLight
import com.example.jetcaster.designsystem.theme.onTertiaryLightHighContrast
import com.example.jetcaster.designsystem.theme.onTertiaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.outlineDark
import com.example.jetcaster.designsystem.theme.outlineDarkHighContrast
import com.example.jetcaster.designsystem.theme.outlineDarkMediumContrast
import com.example.jetcaster.designsystem.theme.outlineLight
import com.example.jetcaster.designsystem.theme.outlineLightHighContrast
import com.example.jetcaster.designsystem.theme.outlineLightMediumContrast
import com.example.jetcaster.designsystem.theme.outlineVariantDark
import com.example.jetcaster.designsystem.theme.outlineVariantDarkHighContrast
import com.example.jetcaster.designsystem.theme.outlineVariantDarkMediumContrast
import com.example.jetcaster.designsystem.theme.outlineVariantLight
import com.example.jetcaster.designsystem.theme.outlineVariantLightHighContrast
import com.example.jetcaster.designsystem.theme.outlineVariantLightMediumContrast
import com.example.jetcaster.designsystem.theme.primaryContainerDark
import com.example.jetcaster.designsystem.theme.primaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.primaryContainerLight
import com.example.jetcaster.designsystem.theme.primaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.primaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.primaryDark
import com.example.jetcaster.designsystem.theme.primaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.primaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.primaryLight
import com.example.jetcaster.designsystem.theme.primaryLightHighContrast
import com.example.jetcaster.designsystem.theme.primaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.scrimDark
import com.example.jetcaster.designsystem.theme.scrimDarkHighContrast
import com.example.jetcaster.designsystem.theme.scrimDarkMediumContrast
import com.example.jetcaster.designsystem.theme.scrimLight
import com.example.jetcaster.designsystem.theme.scrimLightHighContrast
import com.example.jetcaster.designsystem.theme.scrimLightMediumContrast
import com.example.jetcaster.designsystem.theme.secondaryContainerDark
import com.example.jetcaster.designsystem.theme.secondaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.secondaryContainerLight
import com.example.jetcaster.designsystem.theme.secondaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.secondaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.secondaryDark
import com.example.jetcaster.designsystem.theme.secondaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.secondaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.secondaryLight
import com.example.jetcaster.designsystem.theme.secondaryLightHighContrast
import com.example.jetcaster.designsystem.theme.secondaryLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceBrightDark
import com.example.jetcaster.designsystem.theme.surfaceBrightDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceBrightDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceBrightLight
import com.example.jetcaster.designsystem.theme.surfaceBrightLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceBrightLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerDark
import com.example.jetcaster.designsystem.theme.surfaceContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark
import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight
import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark
import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceDark
import com.example.jetcaster.designsystem.theme.surfaceDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceDimDark
import com.example.jetcaster.designsystem.theme.surfaceDimDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceDimDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceDimLight
import com.example.jetcaster.designsystem.theme.surfaceDimLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceDimLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceLight
import com.example.jetcaster.designsystem.theme.surfaceLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceLightMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceVariantDark
import com.example.jetcaster.designsystem.theme.surfaceVariantDarkHighContrast
import com.example.jetcaster.designsystem.theme.surfaceVariantDarkMediumContrast
import com.example.jetcaster.designsystem.theme.surfaceVariantLight
import com.example.jetcaster.designsystem.theme.surfaceVariantLightHighContrast
import com.example.jetcaster.designsystem.theme.surfaceVariantLightMediumContrast
import com.example.jetcaster.designsystem.theme.tertiaryContainerDark
import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkHighContrast
import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkMediumContrast
import com.example.jetcaster.designsystem.theme.tertiaryContainerLight
import com.example.jetcaster.designsystem.theme.tertiaryContainerLightHighContrast
import com.example.jetcaster.designsystem.theme.tertiaryContainerLightMediumContrast
import com.example.jetcaster.designsystem.theme.tertiaryDark
import com.example.jetcaster.designsystem.theme.tertiaryDarkHighContrast
import com.example.jetcaster.designsystem.theme.tertiaryDarkMediumContrast
import com.example.jetcaster.designsystem.theme.tertiaryLight
import com.example.jetcaster.designsystem.theme.tertiaryLightHighContrast
import com.example.jetcaster.designsystem.theme.tertiaryLightMediumContrast
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(val color: Color, val onColor: Color, val colorContainer: Color, val onColorContainer: Color)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified,
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun JetcasterTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
motionScheme = MotionScheme.expressive(),
shapes = JetcasterShapes,
typography = JetcasterTypography,
content = content,
)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt
================================================
/*
* 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.tooling
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "small-phone", device = Devices.PIXEL_4A)
@Preview(name = "phone", device = Devices.PHONE)
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = Devices.FOLDABLE)
@Preview(name = "tablet", device = Devices.TABLET)
annotation class DevicePreviews
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.util
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.IconToggleButtonColors
import androidx.compose.material3.IconToggleButtonShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.jetcaster.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ToggleFollowPodcastIconButton(isFollowed: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
IconToggleButton(
checked = isFollowed,
onCheckedChange = { onClick() },
modifier = modifier,
colors = IconToggleButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary,
checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
checkedContentColor = MaterialTheme.colorScheme.secondary,
),
shapes = IconToggleButtonShapes(
shape = RoundedCornerShape(10.dp),
pressedShape = if (isFollowed) RoundedCornerShape(10.dp) else CircleShape,
checkedShape = CircleShape,
),
) {
Icon(
// TODO: think about animating these icons
imageVector = when {
isFollowed -> Icons.Default.Check
else -> Icons.Default.Add
},
contentDescription = when {
isFollowed -> stringResource(R.string.cd_following)
else -> stringResource(R.string.cd_not_following)
},
)
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import kotlin.math.max
import kotlin.math.min
fun Color.contrastAgainst(background: Color): Float {
val fg = if (alpha < 1f) compositeOver(background) else this
val fgLuminance = fg.luminance() + 0.05f
val bgLuminance = background.luminance() + 0.05f
return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.util
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* Applies a radial gradient scrim in the foreground emanating from the top
* center quarter of the element.
*/
fun Modifier.radialGradientScrim(color: Color): Modifier {
val radialGradient = object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val largerDimension = max(size.height, size.width)
return RadialGradientShader(
center = size.center.copy(y = size.height / 4),
colors = listOf(color, Color.Transparent),
radius = largerDimension / 2,
colorStops = listOf(0f, 0.9f),
)
}
}
return this.background(radialGradient)
}
/**
* Draws a vertical gradient scrim in the foreground.
*
* @param color The color of the gradient scrim.
* @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f)
* @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f). This
* value can be smaller than [startYPercentage]. If that is the case, then the gradient direction
* will reverse (decaying downwards, instead of decaying upwards).
* @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is
* a linear gradient.
* @param numStops The number of color stops to draw in the gradient. Higher numbers result in
* the higher visual quality at the cost of draw performance. Defaults to `16`.
*/
fun Modifier.verticalGradientScrim(
color: Color,
@FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f,
@FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f,
decay: Float = 1.0f,
numStops: Int = 16,
) = this then VerticalGradientElement(color, startYPercentage, endYPercentage, decay, numStops)
private data class VerticalGradientElement(
var color: Color,
var startYPercentage: Float = 0f,
var endYPercentage: Float = 1f,
var decay: Float = 1.0f,
var numStops: Int = 16,
) : ModifierNodeElement<VerticalGradientModifier>() {
fun createOnDraw(): DrawScope.() -> Unit {
val colors = if (decay != 1f) {
// If we have a non-linear decay, we need to create the color gradient steps
// manually
val baseAlpha = color.alpha
List(numStops) { i ->
val x = i * 1f / (numStops - 1)
val opacity = x.pow(decay)
color.copy(alpha = baseAlpha * opacity)
}
} else {
// If we have a linear decay, we just create a simple list of start + end colors
listOf(color.copy(alpha = 0f), color)
}
val brush =
// Reverse the gradient if decaying downwards
Brush.verticalGradient(
colors = if (startYPercentage < endYPercentage) colors else colors.reversed(),
)
return {
val topLeft = Offset(0f, size.height * min(startYPercentage, endYPercentage))
val bottomRight =
Offset(size.width, size.height * max(startYPercentage, endYPercentage))
drawRect(
topLeft = topLeft,
size = Rect(topLeft, bottomRight).size,
brush = brush,
)
}
}
override fun create() = VerticalGradientModifier(createOnDraw())
override fun update(node: VerticalGradientModifier) {
node.onDraw = createOnDraw()
}
/**
* Allow this custom modifier to be inspected in the layout inspector
**/
override fun InspectorInfo.inspectableProperties() {
name = "verticalGradientScrim"
properties["color"] = color
properties["startYPercentage"] = startYPercentage
properties["endYPercentage"] = endYPercentage
properties["decay"] = decay
properties["numStops"] = numStops
}
}
private class VerticalGradientModifier(var onDraw: DrawScope.() -> Unit) :
Modifier.Node(),
DrawModifierNode {
override fun ContentDrawScope.draw() {
onDraw()
drawContent()
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt
================================================
/*
* 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.util
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
/**
* An item that occupies the entire width.
*/
fun LazyGridScope.fullWidthItem(key: Any? = null, contentType: Any? = null, content: @Composable LazyGridItemScope.() -> Unit) = item(
span = { GridItemSpan(this.maxLineSpan) },
key = key,
contentType = contentType,
content = content,
)
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.util
import androidx.annotation.PluralsRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Load a quantity string resource.
*
* @param id the resource identifier
* @param quantity The number used to get the string for the current language's plural rules.
* @return the string data associated with the resource
*/
@Composable
fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String {
val context = LocalContext.current
return context.resources.getQuantityString(id, quantity)
}
/**
* Load a quantity string resource with formatting.
*
* @param id the resource identifier
* @param quantity The number used to get the string for the current language's plural rules.
* @param formatArgs the format arguments
* @return the string data associated with the resource
*/
@Composable
fun quantityStringResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String {
val context = LocalContext.current
return context.resources.getQuantityString(id, quantity, *formatArgs)
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.jetcaster.util
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
/**
* Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's
* [ViewModelProvider.Factory.create] function is called.
*
* If the created [ViewModel] does not match the requested class, an [IllegalArgumentException]
* exception is thrown.
*/
inline fun <reified VM : ViewModel> viewModelProviderFactoryOf(crossinline create: () -> VM): ViewModelProvider.Factory = viewModelFactory {
initializer {
create()
}
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt
================================================
/*
* 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.util
import androidx.window.layout.FoldingFeature
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun isTableTopPosture(foldFeature: FoldingFeature?): Boolean {
contract { returns(true) implies (foldFeature != null) }
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}
@OptIn(ExperimentalContracts::class)
fun isBookPosture(foldFeature: FoldingFeature?): Boolean {
contract { returns(true) implies (foldFeature != null) }
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}
@OptIn(ExperimentalContracts::class)
fun isSeparatingPosture(foldFeature: FoldingFeature?): Boolean {
contract { returns(true) implies (foldFeature != null) }
return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
}
================================================
FILE: Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt
================================================
/*
* 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.util
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
/**
* Returns true if the width or height size classes are compact.
*/
val WindowSizeClass.isCompact: Boolean
get() = windowWidthSizeClass == WindowWidthSizeClass.COMPACT ||
windowHeightSizeClass == WindowHeightSizeClass.COMPACT
================================================
FILE: Jetcaster/mobile/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>
================================================
FILE: Jetcaster/mobile/src/main/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<string name="app_name">Jetcaster</string>
<string name="connection_error_title">Connection error</string>
<string name="connection_error_message">Unable to fetch podcasts feeds.\nCheck your internet connection and try again.</string>
<string name="retry_label">Retry</string>
<string name="your_podcasts">Your podcasts</string>
<string name="latest_episodes">Latest episodes</string>
<string name="home_library">Your library</string>
<string name="home_discover">Discover</string>
<string name="discover_toolbar">Discover</string>
<string name="library_toolbar">Library</string>
<string name="discover_toolbar_content_description">Discover Tab Icon</string>
<string name="library_toolbar_content_description">Library Tab Icon</string>
<string name="updated_longer">Updated a while ago</string>
<plurals name="updated_weeks_ago">
<item quantity="one">Updated %d week ago</item>
<item quantity="other">Updated %d weeks ago</item>
</plurals>
<plurals name="updated_days_ago">
<item quantity="one">Updated yesterday</item>
<item quantity="other">Updated %d days ago</item>
</plurals>
<string name="updated_today">Updated today</string>
<string name="episode_date_duration">%1$s &#8226; %2$d mins</string>
<string name="cd_account">Account</string>
<string name="cd_add">Add</string>
<string name="cd_back">Back</string>
<string name="cd_follow">Follow</string>
<string name="cd_following">Following</string>
<string name="cd_forward10">Forward 10 seconds</string>
<string name="cd_more">More</string>
<string name="cd_not_following">Not following</string>
<string name="cd_pause">Pause</string>
<string name="cd_play">Play</string>
<string name="cd_replay10">Replay 10 seconds</string>
<string name="cd_search">Search</string>
<string name="cd_selected_category">Selected category</string>
<string name="cd_skip_next">Skip next</string>
<string name="cd_skip_previous">Skip previous</string>
<string name="cd_unfollow">Unfollow</string>
<string name="episode_added_to_your_queue">Episode added to your queue</string>
<string name="cd_podcast_image">Podcast image</string>
<string name="subscribe">Subscribe</string>
<string name="subscribed">Subscribed</string>
<string name="see_more">see more</string>
<string name="search_for_a_podcast">Search for a podcast</string>
<string name="an_error_has_occurred">An error has occurred.</string>
</resources>
================================================
FILE: Jetcaster/mobile/src/main/res/values/themes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<style name="Theme.Jetcaster" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">#ff00ff</item>
<item name="android:colorAccent">#ff00ff</item>
</style>
</resources>
================================================
FILE: Jetcaster/tv/build.gradle.kts
================================================
/*
* 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.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.compose)
}
android {
namespace = "com.example.jetcaster.tv"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
defaultConfig {
applicationId = "com.example.jetcaster"
minSdk =
libs.versions.minSdk
.get()
.toInt()
targetSdk =
libs.versions.targetSdk
.get()
.toInt()
versionCode = 1
versionName = "1.0"
vectorDrawables {
useSupportLibrary = true
}
}
signingConfigs {
// Important: change the keystore for a production deployment
val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore")
val localKeystore = rootProject.file("debug_2.keystore")
val hasKeyInfo = userKeystore.exists()
create("release") {
// get from env variables
storeFile = if (hasKeyInfo) userKeystore else localKeystore
storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password")
keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias")
keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password")
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
kotlinOptions {
jvmTarget = "17"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
// The Rome library JARs embed some internal utils libraries in nested JARs.
// We don't need them so we exclude them in the final package.
excludes += "/*.jar"
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
// Dependency injection
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(projects.core.data)
implementation(projects.core.designsystem)
implementation(projects.core.domain)
// Media3
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.ui.compose)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.core.jdk.desugaring)
}
================================================
FILE: Jetcaster/tv/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:banner="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Jetcaster"
android:name=".JetCasterTvApp">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt
================================================
/*
* 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.tv
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class JetCasterTvApp : Application()
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/MainActivity.kt
================================================
/*
* 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.tv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.tv.material3.Surface
import com.example.jetcaster.tv.ui.JetcasterApp
import com.example.jetcaster.tv.ui.theme.JetcasterTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// TV is hardcoded to dark mode to match TV ui
JetcasterTheme(isInDarkTheme = true) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape,
) {
JetcasterApp()
}
}
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategoryInfoList.kt
================================================
/*
* 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.tv.model
import androidx.compose.runtime.Immutable
import com.example.jetcaster.core.data.database.model.Category
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.asExternalModel
@Immutable
data class CategoryInfoList(val member: List<CategoryInfo>) : List<CategoryInfo> by member {
fun intoCategoryList(): List<Category> {
return map(CategoryInfo::intoCategory)
}
companion object {
fun from(list: List<Category>): CategoryInfoList {
val member = list.map(Category::asExternalModel)
return CategoryInfoList(member)
}
}
}
private fun CategoryInfo.intoCategory(): Category {
return Category(id, name)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt
================================================
/*
* 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.tv.model
import androidx.compose.runtime.Immutable
import com.example.jetcaster.core.model.CategoryInfo
data class CategorySelection(val categoryInfo: CategoryInfo, val isSelected: Boolean = false)
@Immutable
data class CategorySelectionList(val member: List<CategorySelection>) : List<CategorySelection> by member
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt
================================================
/*
* 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.tv.model
import androidx.compose.runtime.Immutable
import com.example.jetcaster.core.player.model.PlayerEpisode
@Immutable
data class EpisodeList(val member: List<PlayerEpisode>) : List<PlayerEpisode> by member
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt
================================================
/*
* 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.tv.model
import com.example.jetcaster.core.model.PodcastInfo
typealias PodcastList = List<PodcastInfo>
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt
================================================
/*
* 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.tv.ui
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text
import com.example.jetcaster.tv.ui.discover.DiscoverScreen
import com.example.jetcaster.tv.ui.episode.EpisodeScreen
import com.example.jetcaster.tv.ui.library.LibraryScreen
import com.example.jetcaster.tv.ui.player.PlayerScreen
import com.example.jetcaster.tv.ui.podcast.PodcastDetailsScreen
import com.example.jetcaster.tv.ui.profile.ProfileScreen
import com.example.jetcaster.tv.ui.search.SearchScreen
import com.example.jetcaster.tv.ui.settings.SettingsScreen
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) {
Route(jetcasterAppState = jetcasterAppState)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun GlobalNavigationContainer(
jetcasterAppState: JetcasterAppState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val (discover, library) = remember { FocusRequester.createRefs() }
val currentRoute
by jetcasterAppState.currentRouteFlow.collectAsStateWithLifecycle(initialValue = null)
NavigationDrawer(
drawerContent = {
val isClosed = it == DrawerValue.Closed
Column(
modifier = Modifier
.padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues())
.focusProperties {
enter = {
when (currentRoute) {
Screen.Discover.route -> discover
Screen.Library.route -> library
else -> FocusRequester.Default
}
}
}
.focusGroup(),
) {
NavigationDrawerItem(
selected = isClosed && currentRoute == Screen.Profile.route,
onClick = jetcasterAppState::navigateToProfile,
leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
) {
Column {
Text(text = "Name")
Text(
text = "Switch Account",
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.weight(1f))
NavigationDrawerItem(
selected = isClosed && currentRoute == Screen.Search.route,
onClick = jetcasterAppState::navigateToSearch,
leadingContent = {
Icon(
Icons.Default.Search,
contentDescription = null,
)
},
) {
Text(text = "Search")
}
NavigationDrawerItem(
selected = isClosed && currentRoute == Screen.Discover.route,
onClick = jetcasterAppState::navigateToDiscover,
leadingContent = {
Icon(
Icons.Default.Home,
contentDescription = null,
)
},
modifier = Modifier.focusRequester(discover),
) {
Text(text = "Discover")
}
NavigationDrawerItem(
selected = isClosed && currentRoute == Screen.Library.route,
onClick = jetcasterAppState::navigateToLibrary,
leadingContent = {
Icon(
Icons.Default.VideoLibrary,
contentDescription = null,
)
},
modifier = Modifier.focusRequester(library),
) {
Text(text = "Library")
}
Spacer(modifier = Modifier.weight(1f))
NavigationDrawerItem(
selected = isClosed && currentRoute == Screen.Settings.route,
onClick = jetcasterAppState::navigateToSettings,
leadingContent = { Icon(Icons.Default.Settings, contentDescription = null) },
) {
Text(text = "Settings")
}
}
},
content = content,
modifier = modifier,
)
}
@Composable
private fun Route(jetcasterAppState: JetcasterAppState) {
NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) {
composable(Screen.Discover.route) {
GlobalNavigationContainer(jetcasterAppState = jetcasterAppState) {
DiscoverScreen(
showPodcastDetails = {
jetcasterAppState.showPodcastDetails(it.uri)
},
playEpisode = {
jetcasterAppState.playEpisode()
},
modifier = Modifier.fillMaxSize(),
)
}
}
composable(Screen.Library.route) {
GlobalNavigationContainer(jetcasterAppState = jetcasterAppState) {
LibraryScreen(
navigateToDiscover = jetcasterAppState::navigateToDiscover,
showPodcastDetails = {
jetcasterAppState.showPodcastDetails(it.uri)
},
playEpisode = {
jetcasterAppState.playEpisode()
},
modifier = Modifier.fillMaxSize(),
)
}
}
composable(Screen.Search.route) {
SearchScreen(
onPodcastSelected = {
jetcasterAppState.showPodcastDetails(it.uri)
},
modifier = Modifier
.padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues())
.fillMaxSize(),
)
}
composable(Screen.Podcast.route) {
PodcastDetailsScreen(
backToHomeScreen = jetcasterAppState::navigateToDiscover,
playEpisode = {
jetcasterAppState.playEpisode()
},
showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.uri) },
modifier = Modifier
.padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues())
.fillMaxSize(),
)
}
composable(Screen.Episode.route) {
EpisodeScreen(
playEpisode = {
jetcasterAppState.playEpisode()
},
backToHome = jetcasterAppState::backToHome,
)
}
composable(Screen.Player.route) {
PlayerScreen(
backToHome = jetcasterAppState::backToHome,
modifier = Modifier.fillMaxSize(),
showDetails = jetcasterAppState::showEpisodeDetails,
)
}
composable(Screen.Profile.route) {
ProfileScreen(
modifier = Modifier
.fillMaxSize()
.padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()),
)
}
composable(Screen.Settings.route) {
SettingsScreen(
modifier = Modifier
.fillMaxSize()
.padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()),
)
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt
================================================
/*
* 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.tv.ui
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.example.jetcaster.core.player.model.PlayerEpisode
import kotlinx.coroutines.flow.map
class JetcasterAppState(val navHostController: NavHostController) {
val currentRouteFlow = navHostController.currentBackStackEntryFlow.map {
it.destination.route
}
private fun navigate(screen: Screen) {
navHostController.navigate(screen.route)
}
fun navigateToDiscover() {
navigate(Screen.Discover)
}
fun navigateToLibrary() {
navigate(Screen.Library)
}
fun navigateToProfile() {
navigate(Screen.Profile)
}
fun navigateToSearch() {
navigate(Screen.Search)
}
fun navigateToSettings() {
navigate(Screen.Settings)
}
fun showPodcastDetails(podcastUri: String) {
val encodedUrL = Uri.encode(podcastUri)
val screen = Screen.Podcast(encodedUrL)
navigate(screen)
}
fun showEpisodeDetails(episodeUri: String) {
val encodeUrl = Uri.encode(episodeUri)
val screen = Screen.Episode(encodeUrl)
navigate(screen)
}
fun showEpisodeDetails(playerEpisode: PlayerEpisode) {
showEpisodeDetails(playerEpisode.uri)
}
fun playEpisode() {
navigate(Screen.Player)
}
fun backToHome() {
navHostController.popBackStack()
navigateToDiscover()
}
}
@Composable
fun rememberJetcasterAppState(navHostController: NavHostController = rememberNavController()) = remember(navHostController) {
JetcasterAppState(navHostController)
}
sealed interface Screen {
val route: String
data object Discover : Screen {
override val route = "/discover"
}
data object Library : Screen {
override val route = "/library"
}
data object Search : Screen {
override val route = "/search"
}
data object Profile : Screen {
override val route = "/profile"
}
data object Settings : Screen {
override val route: String = "settings"
}
data class Podcast(private val podcastUri: String) : Screen {
override val route = "$ROOT/$podcastUri"
companion object : Screen {
private const val ROOT = "/podcast"
const val PARAMETER_NAME = "podcastUri"
override val route = "$ROOT/{$PARAMETER_NAME}"
}
}
data class Episode(private val episodeUri: String) : Screen {
override val route: String = "$ROOT/$episodeUri"
companion object : Screen {
private const val ROOT = "/episode"
const val PARAMETER_NAME = "episodeUri"
override val route = "$ROOT/{$PARAMETER_NAME}"
}
}
data object Player : Screen {
override val route = "player"
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.ImageBackgroundRadialGradientScrim
@Composable
internal fun BackgroundContainer(
playerEpisode: PlayerEpisode,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
content: @Composable BoxScope.() -> Unit,
) = BackgroundContainer(
imageUrl = playerEpisode.podcastImageUrl,
modifier,
contentAlignment,
content,
)
@Composable
internal fun BackgroundContainer(
podcastInfo: PodcastInfo,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
content: @Composable BoxScope.() -> Unit,
) = BackgroundContainer(imageUrl = podcastInfo.imageUrl, modifier, contentAlignment, content)
@Composable
internal fun BackgroundContainer(
imageUrl: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
content: @Composable BoxScope.() -> Unit,
) {
Box(modifier = modifier, contentAlignment = contentAlignment) {
Background(imageUrl = imageUrl, modifier = Modifier.fillMaxSize())
content()
}
}
@Composable
private fun Background(imageUrl: String, modifier: Modifier = Modifier) {
ImageBackgroundRadialGradientScrim(
url = imageUrl,
colors = listOf(Color.Black, Color.Transparent),
modifier = modifier,
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Forward10
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.ButtonScale
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButton
import com.example.jetcaster.tv.R
@Composable
internal fun PlayButton(onClick: () -> Unit, modifier: Modifier = Modifier, scale: ButtonScale = ButtonDefaults.scale()) = ButtonWithIcon(
icon = Icons.Outlined.PlayArrow,
label = stringResource(R.string.label_play),
onClick = onClick,
modifier = modifier,
scale = scale,
)
@Composable
internal fun EnqueueButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = stringResource(R.string.label_add_playlist),
)
}
}
@Composable
internal fun InfoButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.Outlined.Info,
contentDescription = stringResource(R.string.label_info),
)
}
}
@Composable
internal fun PreviousButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = stringResource(R.string.label_previous_episode),
)
}
}
@Composable
internal fun NextButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.Default.SkipNext,
contentDescription = stringResource(R.string.label_next_episode),
)
}
}
@Composable
internal fun PlayPauseButton(isPlaying: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val (icon, description) = if (isPlaying) {
Icons.Default.Pause to stringResource(R.string.label_pause)
} else {
Icons.Default.PlayArrow to stringResource(R.string.label_play)
}
IconButton(onClick = onClick, modifier = modifier) {
Icon(icon, description, modifier = Modifier.size(48.dp))
}
}
@Composable
internal fun RewindButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.Default.Replay10,
contentDescription = stringResource(R.string.label_rewind),
)
}
}
@Composable
internal fun SkipButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(
Icons.Default.Forward10,
contentDescription = stringResource(R.string.label_skip),
)
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.ButtonScale
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
@Composable
internal fun ButtonWithIcon(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
scale: ButtonScale = ButtonDefaults.scale(),
) {
Button(onClick = onClick, modifier = modifier, scale = scale) {
Icon(
icon,
contentDescription = null,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = label)
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.model.PodcastList
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
internal fun Catalog(
podcastList: PodcastList,
latestEpisodeList: EpisodeList,
onPodcastSelected: (PodcastInfo) -> Unit,
onEpisodeSelected: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
header: (@Composable () -> Unit)? = null,
) {
LazyColumn(
modifier = modifier,
contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(),
verticalArrangement =
Arrangement.spacedBy(JetcasterAppDefaults.gap.section),
state = state,
) {
if (header != null) {
item { header() }
}
item {
PodcastSection(
podcastList = podcastList,
onPodcastSelected = onPodcastSelected,
title = stringResource(R.string.label_podcast),
)
}
item {
LatestEpisodeSection(
episodeList = latestEpisodeList,
onEpisodeSelected = onEpisodeSelected,
title = stringResource(R.string.label_latest_episode),
)
}
}
}
@Composable
private fun PodcastSection(
podcastList: PodcastList,
onPodcastSelected: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
) {
Section(
title = title,
modifier = modifier,
) {
PodcastRow(
podcastList = podcastList,
onPodcastSelected = onPodcastSelected,
)
}
}
@Composable
private fun LatestEpisodeSection(
episodeList: EpisodeList,
onEpisodeSelected: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
) {
Section(
modifier = modifier,
title = title,
) {
EpisodeRow(
playerEpisodeList = episodeList,
onSelected = onEpisodeSelected,
)
}
}
@Composable
private fun Section(
modifier: Modifier = Modifier,
title: String? = null,
style: TextStyle = MaterialTheme.typography.headlineMedium,
content: @Composable () -> Unit,
) {
Column(modifier) {
if (title != null) {
Text(
text = title,
style = style,
modifier = Modifier.padding(JetcasterAppDefaults.padding.sectionTitle),
)
}
content()
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PodcastRow(
podcastList: PodcastList,
onPodcastSelected: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = JetcasterAppDefaults.padding.podcastRowContentPadding,
horizontalArrangement: Arrangement.Horizontal =
Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow),
) {
val (focusRequester, firstItem) = remember(podcastList) { FocusRequester.createRefs() }
LazyRow(
contentPadding = contentPadding,
horizontalArrangement = horizontalArrangement,
modifier = modifier
.focusRequester(focusRequester)
.focusProperties {
exit = {
focusRequester.saveFocusedChild()
FocusRequester.Default
}
enter = {
if (focusRequester.restoreFocusedChild()) {
FocusRequester.Cancel
} else {
firstItem
}
}
},
) {
itemsIndexed(podcastList) { index, podcastInfo ->
val cardModifier = if (index == 0) {
Modifier.focusRequester(firstItem)
} else {
Modifier
}
PodcastCard(
podcastInfo = podcastInfo,
onClick = { onPodcastSelected(podcastInfo) },
modifier = cardModifier.width(JetcasterAppDefaults.cardWidth.medium),
)
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.CardScale
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.WideCardContainer
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
internal fun EpisodeCard(
playerEpisode: PlayerEpisode,
onClick: () -> Unit,
modifier: Modifier = Modifier,
cardSize: DpSize = JetcasterAppDefaults.thumbnailSize.episode,
) {
WideCardContainer(
imageCard = {
EpisodeThumbnail(playerEpisode, onClick = onClick, modifier = Modifier.size(cardSize))
},
title = {
EpisodeMetaData(
playerEpisode = playerEpisode,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.width(JetcasterAppDefaults.cardWidth.small * 2),
)
},
modifier = modifier,
)
}
@Composable
private fun EpisodeThumbnail(
playerEpisode: PlayerEpisode,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Card(
onClick = onClick,
interactionSource = interactionSource,
scale = CardScale.None,
shape = CardDefaults.shape(RoundedCornerShape(12.dp)),
modifier = modifier,
) {
Thumbnail(episode = playerEpisode, size = JetcasterAppDefaults.thumbnailSize.episode)
}
}
@Composable
private fun EpisodeMetaData(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) {
val duration = playerEpisode.duration
Column(modifier = modifier) {
Text(
text = playerEpisode.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(text = playerEpisode.podcastName, style = MaterialTheme.typography.bodySmall)
if (duration != null) {
Spacer(
modifier = Modifier.height(JetcasterAppDefaults.gap.podcastRow * 0.8f),
)
EpisodeDataAndDuration(offsetDateTime = playerEpisode.published, duration = duration)
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.tv.R
import java.time.Duration
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
private val MediumDateFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
}
@Composable
internal fun EpisodeDataAndDuration(
offsetDateTime: OffsetDateTime,
duration: Duration,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodySmall,
) {
Text(
text = stringResource(
R.string.episode_date_duration,
MediumDateFormatter.format(offsetDateTime),
duration.toMinutes().toInt(),
),
style = style,
modifier = modifier,
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
internal fun EpisodeDetails(
playerEpisode: PlayerEpisode,
modifier: Modifier = Modifier,
controls: (@Composable () -> Unit)? = null,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
content: @Composable ColumnScope.() -> Unit,
) {
TwoColumn(
modifier = modifier,
first = {
Thumbnail(
playerEpisode,
size = JetcasterAppDefaults.thumbnailSize.episodeDetails,
)
},
second = {
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
EpisodeAuthor(playerEpisode = playerEpisode)
EpisodeTitle(playerEpisode = playerEpisode)
content()
if (controls != null) {
controls()
}
}
},
)
}
@Composable
internal fun EpisodeAuthor(
playerEpisode: PlayerEpisode,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodySmall,
) {
Text(text = playerEpisode.author, modifier = modifier, style = style)
}
@Composable
internal fun EpisodeTitle(
playerEpisode: PlayerEpisode,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.headlineLarge,
) {
Text(text = playerEpisode.title, modifier = modifier, style = style)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun EpisodeRow(
playerEpisodeList: EpisodeList,
onSelected: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal =
Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
contentPadding: PaddingValues = JetcasterAppDefaults.padding.episodeRowContentPadding,
focusRequester: FocusRequester = remember { FocusRequester() },
lazyListState: LazyListState = remember(playerEpisodeList) { LazyListState() },
) {
val firstItem = remember { FocusRequester() }
var previousEpisodeListHash by remember { mutableIntStateOf(playerEpisodeList.hashCode()) }
val isSameList = previousEpisodeListHash == playerEpisodeList.hashCode()
LazyRow(
state = lazyListState,
modifier = Modifier
.focusRequester(focusRequester)
.focusProperties {
enter = {
when {
lazyListState.layoutInfo.visibleItemsInfo.isEmpty() -> FocusRequester.Cancel
isSameList && focusRequester.restoreFocusedChild() -> FocusRequester.Cancel
else -> firstItem
}
}
exit = {
previousEpisodeListHash = playerEpisodeList.hashCode()
focusRequester.saveFocusedChild()
FocusRequester.Default
}
}
.then(modifier),
contentPadding = contentPadding,
horizontalArrangement = horizontalArrangement,
) {
itemsIndexed(playerEpisodeList) { index, item ->
val cardModifier = if (index == 0) {
Modifier.focusRequester(firstItem)
} else {
Modifier
}
EpisodeCard(
playerEpisode = item,
onClick = { onSelected(item) },
modifier = cardModifier,
)
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun ErrorState(backToHome: () -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column {
Text(
text = stringResource(R.string.display_error_state),
style = MaterialTheme.typography.displayMedium,
)
Button(
onClick = backToHome,
modifier
.padding(top = JetcasterAppDefaults.gap.podcastRow)
.focusRequester(focusRequester),
) {
Text(text = stringResource(R.string.label_back_to_home))
}
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.max
@Composable
fun Loading(
modifier: Modifier = Modifier,
message: String = stringResource(id = R.string.message_loading),
contentAlignment: Alignment = Alignment.Center,
style: TextStyle = MaterialTheme.typography.displaySmall,
) {
Box(
modifier = modifier,
contentAlignment = contentAlignment,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.default),
) {
CircularProgressIndicator()
Text(text = message, style = style)
}
}
}
@Composable
fun CircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
strokeWidth: Dp = 4.dp,
trackColor: Color = MaterialTheme.colorScheme.surface,
strokeCap: StrokeCap = StrokeCap.Round,
) {
val transition = rememberInfiniteTransition("loading")
val stroke = with(LocalDensity.current) {
Stroke(width = strokeWidth.toPx(), cap = strokeCap)
}
val currentRotation = transition.animateValue(
0,
RotationsPerCycle,
Int.VectorConverter,
infiniteRepeatable(
animation = tween(
durationMillis = RotationDuration * RotationsPerCycle,
easing = LinearEasing,
),
),
"loading_current_rotation",
)
// How far forward (degrees) the base point should be from the start point
val baseRotation = transition.animateFloat(
0f,
BaseRotationAngle,
infiniteRepeatable(
animation = tween(
durationMillis = RotationDuration,
easing = LinearEasing,
),
),
"loading_base_rotation_angle",
)
// How far forward (degrees) both the head and tail should be from the base point
val endAngle = transition.animateFloat(
0f,
JumpRotationAngle,
infiniteRepeatable(
animation = keyframes {
durationMillis = HeadAndTailAnimationDuration + HeadAndTailDelayDuration
0f at 0 using CircularEasing
JumpRotationAngle at HeadAndTailAnimationDuration
},
),
"loading_end_rotation_angle",
)
val startAngle = transition.animateFloat(
0f,
JumpRotationAngle,
infiniteRepeatable(
animation = keyframes {
durationMillis = HeadAndTailAnimationDuration + HeadAndTailDelayDuration
0f at HeadAndTailDelayDuration using CircularEasing
JumpRotationAngle at durationMillis
},
),
"loading_start_angle",
)
Canvas(
modifier
.progressSemantics()
.size(CircularIndicatorDiameter),
) {
drawCircularIndicatorTrack(trackColor, stroke)
val currentRotationAngleOffset = (currentRotation.value * RotationAngleOffset) % 360f
// How long a line to draw using the start angle as a reference point
val sweep = abs(endAngle.value - startAngle.value)
// Offset by the constant offset and the per rotation offset
val offset = StartAngleOffset + currentRotationAngleOffset + baseRotation.value
drawIndeterminateCircularIndicator(
startAngle.value + offset,
strokeWidth,
sweep,
color,
stroke,
)
}
}
private fun DrawScope.drawCircularIndicator(startAngle: Float, sweep: Float, color: Color, stroke: Stroke) {
// To draw this circle we need a rect with edges that line up with the midpoint of the stroke.
// To do this we need to remove half the stroke width from the total diameter for both sides.
val diameterOffset = stroke.width / 2
val arcDimen = size.width - 2 * diameterOffset
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(diameterOffset, diameterOffset),
size = Size(arcDimen, arcDimen),
style = stroke,
)
}
private fun DrawScope.drawCircularIndicatorTrack(color: Color, stroke: Stroke) = drawCircularIndicator(0f, 360f, color, stroke)
private fun DrawScope.drawIndeterminateCircularIndicator(startAngle: Float, strokeWidth: Dp, sweep: Float, color: Color, stroke: Stroke) {
val strokeCapOffset = if (stroke.cap == StrokeCap.Butt) {
0f
} else {
// Length of arc is angle * radius
// Angle (radians) is length / radius
// The length should be the same as the stroke width for calculating the min angle
(180.0 / PI).toFloat() * (strokeWidth / (CircularIndicatorDiameter / 2)) / 2f
}
// Adding a stroke cap draws half the stroke width behind the start point, so we want to
// move it forward by that amount so the arc visually appears in the correct place
val adjustedStartAngle = startAngle + strokeCapOffset
// When the start and end angles are in the same place, we still want to draw a small sweep, so
// the stroke caps get added on both ends and we draw the correct minimum length arc
val adjustedSweep = max(sweep, 0.1f)
drawCircularIndicator(adjustedStartAngle, adjustedSweep, color, stroke)
}
private val CircularIndicatorDiameter = 38.dp
private const val RotationsPerCycle = 5
private const val RotationDuration = 1332
private const val BaseRotationAngle = 286f
private const val JumpRotationAngle = 290f
private const val HeadAndTailAnimationDuration = (RotationDuration * 0.5).toInt()
private const val HeadAndTailDelayDuration = HeadAndTailAnimationDuration
private val CircularEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
private const val StartAngleOffset = -90f
private const val RotationAngleOffset = (BaseRotationAngle + JumpRotationAngle) % 360f
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.tv.material3.Text
import com.example.jetcaster.tv.R
@Composable
internal fun NotAvailableFeature(
modifier: Modifier = Modifier,
message: String = stringResource(id = R.string.message_not_available_feature),
) {
Text(message, modifier = modifier)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/PodcastCard.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.CardScale
import androidx.tv.material3.StandardCardContainer
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
internal fun PodcastCard(podcastInfo: PodcastInfo, onClick: () -> Unit, modifier: Modifier = Modifier) {
StandardCardContainer(
imageCard = {
Card(
onClick = onClick,
interactionSource = it,
scale = CardScale.None,
shape = CardDefaults.shape(RoundedCornerShape(12.dp)),
) {
Thumbnail(
podcastInfo = podcastInfo,
size = JetcasterAppDefaults.thumbnailSize.podcast,
)
}
},
title = {
Text(text = podcastInfo.title, modifier = Modifier.padding(top = 12.dp))
},
modifier = modifier,
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import java.time.Duration
@Composable
internal fun Seekbar(
timeElapsed: Duration,
length: Duration,
modifier: Modifier = Modifier,
onMoveLeft: () -> Unit = {},
onMoveRight: () -> Unit = {},
knobSize: Dp = 8.dp,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
color: Color = MaterialTheme.colorScheme.onSurface,
) {
val brush = SolidColor(color)
val isFocused by interactionSource.collectIsFocusedAsState()
val outlineSize = knobSize * 1.5f
Box(
modifier
.drawWithCache {
onDrawBehind {
val knobRadius = knobSize.toPx() / 2
val start = Offset.Zero.copy(y = knobRadius)
val end = start.copy(x = size.width)
val knobCenter = start.copy(
x = timeElapsed.seconds.toFloat() / length.seconds.toFloat() * size.width,
)
drawLine(
brush, start, end,
)
if (isFocused) {
val outlineColor = color.copy(alpha = 0.6f)
drawCircle(outlineColor, outlineSize.toPx() / 2, knobCenter)
}
drawCircle(brush, knobRadius, knobCenter)
}
}
.height(outlineSize)
.focusable(true, interactionSource)
.onKeyEvent {
when {
it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft -> {
onMoveLeft()
true
}
it.type == KeyEventType.KeyUp && it.key == Key.DirectionRight -> {
onMoveRight()
true
}
else -> false
}
},
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.designsystem.component.PodcastImage
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun Thumbnail(
podcastInfo: PodcastInfo,
modifier: Modifier = Modifier,
shape: RoundedCornerShape = RoundedCornerShape(12.dp),
size: DpSize = DpSize(
JetcasterAppDefaults.cardWidth.medium,
JetcasterAppDefaults.cardWidth.medium,
),
contentScale: ContentScale = ContentScale.Crop,
) = Thumbnail(
podcastInfo.imageUrl,
modifier,
shape,
size,
contentScale,
)
@Composable
fun Thumbnail(
episode: PlayerEpisode,
modifier: Modifier = Modifier,
shape: RoundedCornerShape = RoundedCornerShape(12.dp),
size: DpSize = DpSize(
JetcasterAppDefaults.cardWidth.medium,
JetcasterAppDefaults.cardWidth.medium,
),
contentScale: ContentScale = ContentScale.Crop,
) = Thumbnail(
episode.podcastImageUrl,
modifier,
shape,
size,
contentScale,
)
@Composable
fun Thumbnail(
url: String,
modifier: Modifier = Modifier,
shape: RoundedCornerShape = RoundedCornerShape(12.dp),
size: DpSize = DpSize(
JetcasterAppDefaults.cardWidth.medium,
JetcasterAppDefaults.cardWidth.medium,
),
contentScale: ContentScale = ContentScale.Crop,
) = PodcastImage(
podcastImageUrl = url,
contentDescription = null,
contentScale = contentScale,
modifier = modifier
.clip(shape)
.size(size),
)
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt
================================================
/*
* 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.tv.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
internal fun TwoColumn(
first: (@Composable RowScope.() -> Unit),
second: (@Composable RowScope.() -> Unit),
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal =
Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn),
) {
Row(
horizontalArrangement = horizontalArrangement,
modifier = modifier,
) {
first()
second()
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt
================================================
/*
* 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.tv.ui.discover
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.Tab
import androidx.tv.material3.TabRow
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.model.CategoryInfoList
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.model.PodcastList
import com.example.jetcaster.tv.ui.component.Catalog
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun DiscoverScreen(
showPodcastDetails: (PodcastInfo) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
discoverScreenViewModel: DiscoverScreenViewModel = hiltViewModel(),
) {
val uiState by discoverScreenViewModel.uiState.collectAsState()
when (val s = uiState) {
DiscoverScreenUiState.Loading -> {
Loading(
modifier = Modifier
.fillMaxSize()
.then(modifier),
)
}
is DiscoverScreenUiState.Ready -> {
CatalogWithCategorySelection(
categoryInfoList = s.categoryInfoList,
podcastList = s.podcastList,
selectedCategory = s.selectedCategory,
latestEpisodeList = s.latestEpisodeList,
onPodcastSelected = showPodcastDetails,
onCategorySelected = discoverScreenViewModel::selectCategory,
onEpisodeSelected = {
discoverScreenViewModel.play(it)
playEpisode(it)
},
modifier = Modifier
.fillMaxSize()
.then(modifier),
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CatalogWithCategorySelection(
categoryInfoList: CategoryInfoList,
podcastList: PodcastList,
selectedCategory: CategoryInfo,
latestEpisodeList: EpisodeList,
onPodcastSelected: (PodcastInfo) -> Unit,
onEpisodeSelected: (PlayerEpisode) -> Unit,
onCategorySelected: (CategoryInfo) -> Unit,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
) {
val (focusRequester, selectedTab) = remember {
FocusRequester.createRefs()
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
val selectedTabIndex = categoryInfoList.indexOf(selectedCategory)
Catalog(
podcastList = podcastList,
latestEpisodeList = latestEpisodeList,
onPodcastSelected = {
focusRequester.saveFocusedChild()
onPodcastSelected(it)
},
onEpisodeSelected = {
focusRequester.saveFocusedChild()
onEpisodeSelected(it)
},
modifier = modifier.focusRequester(focusRequester),
state = state,
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier.focusProperties {
enter = {
selectedTab
}
},
) {
categoryInfoList.forEachIndexed { index, category ->
val tabModifier = if (selectedTabIndex == index) {
Modifier.focusRequester(selectedTab)
} else {
Modifier
}
Tab(
selected = index == selectedTabIndex,
onFocus = {
onCategorySelected(category)
},
modifier = tabModifier,
) {
Text(
text = category.name,
modifier = Modifier.padding(JetcasterAppDefaults.padding.tab),
)
}
}
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt
================================================
/*
* 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.tv.ui.discover
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
import com.example.jetcaster.core.model.CategoryInfo
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.tv.model.CategoryInfoList
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.model.PodcastList
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class DiscoverScreenViewModel @Inject constructor(
private val podcastsRepository: PodcastsRepository,
private val categoryStore: CategoryStore,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
private val _selectedCategory = MutableStateFlow<CategoryInfo?>(null)
private val categoryListFlow = categoryStore
.categoriesSortedByPodcastCount()
.map { categoryList ->
categoryList.map { category ->
CategoryInfo(
id = category.id,
name = category.name.filter { !it.isWhitespace() },
)
}
}
private val selectedCategoryFlow = combine(
categoryListFlow,
_selectedCategory,
) { categoryList, category ->
category ?: categoryList.firstOrNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
private val podcastInSelectedCategory = selectedCategoryFlow.flatMapLatest {
if (it != null) {
categoryStore.podcastsInCategorySortedByPodcastCount(it.id, limit = 10)
} else {
flowOf(emptyList())
}
}.map { list ->
list.map { it.asExternalModel() }
}
@OptIn(ExperimentalCoroutinesApi::class)
private val latestEpisodeFlow = selectedCategoryFlow.flatMapLatest {
if (it != null) {
categoryStore.episodesFromPodcastsInCategory(it.id, 20)
} else {
flowOf(emptyList())
}
}.map { list ->
EpisodeList(list.map { it.toPlayerEpisode() })
}
val uiState = combine(
categoryListFlow,
selectedCategoryFlow,
podcastInSelectedCategory,
latestEpisodeFlow,
) { categoryList, category, podcastList, latestEpisodes ->
if (category != null) {
DiscoverScreenUiState.Ready(
CategoryInfoList(categoryList),
category,
podcastList,
latestEpisodes,
)
} else {
DiscoverScreenUiState.Loading
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
DiscoverScreenUiState.Loading,
)
init {
refresh()
}
fun selectCategory(category: CategoryInfo) {
_selectedCategory.value = category
}
fun play(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
private fun refresh() {
viewModelScope.launch {
podcastsRepository.updatePodcasts(false)
}
}
}
sealed interface DiscoverScreenUiState {
data object Loading : DiscoverScreenUiState
data class Ready(
val categoryInfoList: CategoryInfoList,
val selectedCategory: CategoryInfo,
val podcastList: PodcastList,
val latestEpisodeList: EpisodeList,
) : DiscoverScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt
================================================
/*
* 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.tv.ui.episode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.text.style.TextOverflow
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.ui.component.BackgroundContainer
import com.example.jetcaster.tv.ui.component.EnqueueButton
import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration
import com.example.jetcaster.tv.ui.component.ErrorState
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.component.PlayButton
import com.example.jetcaster.tv.ui.component.Thumbnail
import com.example.jetcaster.tv.ui.component.TwoColumn
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun EpisodeScreen(
playEpisode: () -> Unit,
backToHome: () -> Unit,
modifier: Modifier = Modifier,
episodeScreenViewModel: EpisodeScreenViewModel = hiltViewModel(),
) {
val uiState by episodeScreenViewModel.uiStateFlow.collectAsState()
val screenModifier = modifier.fillMaxSize()
when (val s = uiState) {
EpisodeScreenUiState.Loading -> Loading(modifier = screenModifier)
EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = screenModifier)
is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground(
playerEpisode = s.playerEpisode,
playEpisode = {
episodeScreenViewModel.play(it)
playEpisode()
},
addPlayList = episodeScreenViewModel::addPlayList,
modifier = screenModifier,
)
}
}
@Composable
private fun EpisodeDetailsWithBackground(
playerEpisode: PlayerEpisode,
playEpisode: (PlayerEpisode) -> Unit,
addPlayList: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
BackgroundContainer(
playerEpisode = playerEpisode,
contentAlignment = Alignment.Center,
modifier = modifier,
) {
EpisodeDetails(
playerEpisode = playerEpisode,
playEpisode = playEpisode,
addPlayList = addPlayList,
modifier = Modifier
.padding(JetcasterAppDefaults.overScanMargin.episode.intoPaddingValues()),
)
}
}
@Composable
private fun EpisodeDetails(
playerEpisode: PlayerEpisode,
playEpisode: (PlayerEpisode) -> Unit,
addPlayList: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
TwoColumn(
first = {
Thumbnail(
episode = playerEpisode,
size = JetcasterAppDefaults.thumbnailSize.episodeDetails,
)
},
second = {
EpisodeInfo(
playerEpisode = playerEpisode,
playEpisode = { playEpisode(playerEpisode) },
addPlayList = { addPlayList(playerEpisode) },
modifier = Modifier.weight(1f),
)
},
modifier = modifier,
)
}
@Composable
private fun EpisodeInfo(playerEpisode: PlayerEpisode, playEpisode: () -> Unit, addPlayList: () -> Unit, modifier: Modifier = Modifier) {
val duration = playerEpisode.duration
Column(modifier) {
Text(text = playerEpisode.author, style = MaterialTheme.typography.bodySmall)
Text(text = playerEpisode.title, style = MaterialTheme.typography.headlineLarge)
if (duration != null) {
EpisodeDataAndDuration(offsetDateTime = playerEpisode.published, duration = duration)
}
Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph))
Text(
text = playerEpisode.summary,
softWrap = true,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph))
Controls(playEpisode = playEpisode, addPlayList = addPlayList)
}
}
@Composable
private fun Controls(playEpisode: () -> Unit, addPlayList: () -> Unit, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
PlayButton(onClick = playEpisode)
EnqueueButton(onClick = addPlayList)
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt
================================================
/*
* 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.tv.ui.episode
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
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.tv.ui.Screen
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class EpisodeScreenViewModel @Inject constructor(
handle: SavedStateHandle,
podcastsRepository: PodcastsRepository,
episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
private val episodeUriFlow = handle.getStateFlow<String?>(Screen.Episode.PARAMETER_NAME, null)
@OptIn(ExperimentalCoroutinesApi::class)
private val episodeToPodcastFlow = episodeUriFlow.flatMapLatest {
if (it != null) {
episodeStore.episodeAndPodcastWithUri(it)
} else {
flowOf(null)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
null,
)
val uiStateFlow = episodeToPodcastFlow.map {
if (it != null) {
EpisodeScreenUiState.Ready(it.toPlayerEpisode())
} else {
EpisodeScreenUiState.Error
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
EpisodeScreenUiState.Loading,
)
fun addPlayList(episode: PlayerEpisode) {
episodePlayer.addToQueue(episode)
}
fun play(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
init {
viewModelScope.launch {
podcastsRepository.updatePodcasts(false)
}
}
}
sealed interface EpisodeScreenUiState {
data object Loading : EpisodeScreenUiState
data object Error : EpisodeScreenUiState
data class Ready(val playerEpisode: PlayerEpisode) : EpisodeScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt
================================================
/*
* 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.tv.ui.library
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.model.PodcastList
import com.example.jetcaster.tv.ui.component.Catalog
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun LibraryScreen(
modifier: Modifier = Modifier,
navigateToDiscover: () -> Unit,
showPodcastDetails: (PodcastInfo) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
libraryScreenViewModel: LibraryScreenViewModel = hiltViewModel(),
) {
val uiState by libraryScreenViewModel.uiState.collectAsState()
when (val s = uiState) {
LibraryScreenUiState.Loading -> Loading(modifier = modifier)
LibraryScreenUiState.NoSubscribedPodcast -> {
NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier)
}
is LibraryScreenUiState.Ready -> Library(
podcastList = s.subscribedPodcastList,
episodeList = s.latestEpisodeList,
showPodcastDetails = showPodcastDetails,
onEpisodeSelected = {
libraryScreenViewModel.playEpisode(it)
playEpisode(it)
},
modifier = modifier,
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Library(
podcastList: PodcastList,
episodeList: EpisodeList,
showPodcastDetails: (PodcastInfo) -> Unit,
onEpisodeSelected: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Catalog(
podcastList = podcastList,
latestEpisodeList = episodeList,
onPodcastSelected = showPodcastDetails,
onEpisodeSelected = onEpisodeSelected,
modifier = modifier
.focusRequester(focusRequester)
.focusRestorer(),
)
}
@Composable
private fun NavigateToDiscover(onNavigationRequested: () -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column {
Text(
text = stringResource(id = R.string.display_no_subscribed_podcast),
style = MaterialTheme.typography.displayMedium,
)
Text(text = stringResource(id = R.string.message_no_subscribed_podcast))
Button(
onClick = onNavigationRequested,
modifier = Modifier
.padding(top = JetcasterAppDefaults.gap.podcastRow)
.focusRequester(focusRequester),
) {
Text(text = stringResource(id = R.string.label_navigate_to_discover))
}
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt
================================================
/*
* 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.tv.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
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.tv.model.EpisodeList
import com.example.jetcaster.tv.model.PodcastList
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class LibraryScreenViewModel @Inject constructor(
private val podcastsRepository: PodcastsRepository,
private val episodeStore: EpisodeStore,
podcastStore: PodcastStore,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
private val followingPodcastListFlow =
podcastStore.followedPodcastsSortedByLastEpisode().map { list ->
list.map { it.asExternalModel() }
}
@OptIn(ExperimentalCoroutinesApi::class)
private val latestEpisodeListFlow = podcastStore
.followedPodcastsSortedByLastEpisode()
.flatMapLatest { podcastList ->
if (podcastList.isNotEmpty()) {
combine(podcastList.map { episodeStore.episodesInPodcast(it.podcast.uri, 1) }) {
it.map { episodes ->
episodes.first()
}
}
} else {
flowOf(emptyList())
}
}.map { list ->
EpisodeList(list.map { it.toPlayerEpisode() })
}
val uiState =
combine(followingPodcastListFlow, latestEpisodeListFlow) { podcastList, episodeList ->
if (podcastList.isEmpty()) {
LibraryScreenUiState.NoSubscribedPodcast
} else {
LibraryScreenUiState.Ready(podcastList, episodeList)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LibraryScreenUiState.Loading,
)
init {
viewModelScope.launch {
podcastsRepository.updatePodcasts(false)
}
}
fun playEpisode(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
}
sealed interface LibraryScreenUiState {
data object Loading : LibraryScreenUiState
data object NoSubscribedPodcast : LibraryScreenUiState
data class Ready(val subscribedPodcastList: PodcastList, val latestEpisodeList: EpisodeList) : LibraryScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt
================================================
/*
* 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.tv.ui.player
import android.content.Context
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
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.REPEAT_MODE_ALL
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.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.player.EpisodePlayerState
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.ui.component.BackgroundContainer
import com.example.jetcaster.tv.ui.component.EnqueueButton
import com.example.jetcaster.tv.ui.component.EpisodeDetails
import com.example.jetcaster.tv.ui.component.EpisodeRow
import com.example.jetcaster.tv.ui.component.InfoButton
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.component.NextButton
import com.example.jetcaster.tv.ui.component.PlayPauseButton
import com.example.jetcaster.tv.ui.component.PreviousButton
import com.example.jetcaster.tv.ui.component.RewindButton
import com.example.jetcaster.tv.ui.component.Seekbar
import com.example.jetcaster.tv.ui.component.SkipButton
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
import java.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun PlayerScreen(
backToHome: () -> Unit,
showDetails: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
playScreenViewModel: PlayerScreenViewModel = hiltViewModel(),
) {
val uiState by playScreenViewModel.uiStateFlow.collectAsStateWithLifecycle()
when (val s = uiState) {
PlayerScreenUiState.Loading -> Loading(modifier)
PlayerScreenUiState.NoEpisodeInQueue -> {
NoEpisodeInQueue(backToHome = backToHome, modifier = modifier)
}
is PlayerScreenUiState.Ready -> {
Player(
episodePlayerState = s.playerState,
play = playScreenViewModel::play,
pause = playScreenViewModel::pause,
previous = playScreenViewModel::previous,
next = playScreenViewModel::next,
skip = playScreenViewModel::skip,
rewind = playScreenViewModel::rewind,
enqueue = playScreenViewModel::enqueue,
playEpisode = playScreenViewModel::play,
showDetails = showDetails,
)
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
private fun Player(
episodePlayerState: EpisodePlayerState,
play: () -> Unit,
pause: () -> Unit,
previous: () -> Unit,
next: () -> Unit,
skip: () -> Unit,
rewind: () -> Unit,
enqueue: (PlayerEpisode) -> Unit,
showDetails: (PlayerEpisode) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
autoStart: Boolean = true,
) {
LaunchedEffect(key1 = autoStart) {
if (autoStart && !episodePlayerState.isPlaying) {
play()
}
}
val currentEpisode = episodePlayerState.currentEpisode
if (currentEpisode != null) {
val context = LocalContext.current
val exoPlayer = rememberPlayer(context)
DisposableEffect(exoPlayer, playEpisode) {
exoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(currentEpisode.mediaUrls[0])))
val mediaSession = MediaSession.Builder(context, exoPlayer).build()
exoPlayer.prepare()
exoPlayer.play()
onDispose {
mediaSession.release()
exoPlayer.release()
}
}
// Adding PlayerSurface at the bottom of the stack
// as it is just the audio player
Box {
PlayerSurface(
player = exoPlayer,
modifier = Modifier.resizeWithContentScale(
contentScale = ContentScale.Fit,
sourceSizeDp = null,
),
)
EpisodePlayerWithBackground(
playerEpisode = currentEpisode,
queue = EpisodeList(episodePlayerState.queue),
isPlaying = episodePlayerState.isPlaying,
timeElapsed = episodePlayerState.timeElapsed,
play = (
{
play()
exoPlayer.play()
}
),
pause = (
{
pause()
exoPlayer.pause()
}
),
previous = previous,
next = next,
skip = (
{
skip()
exoPlayer.seekForward()
}
),
rewind = (
{
rewind()
exoPlayer.seekBack()
}
),
enqueue = enqueue,
showDetails = showDetails,
playEpisode = playEpisode,
modifier = modifier,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EpisodePlayerWithBackground(
playerEpisode: PlayerEpisode,
queue: EpisodeList,
isPlaying: Boolean,
timeElapsed: Duration,
play: () -> Unit,
pause: () -> Unit,
previous: () -> Unit,
next: () -> Unit,
skip: () -> Unit,
rewind: () -> Unit,
enqueue: (PlayerEpisode) -> Unit,
showDetails: (PlayerEpisode) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
val episodePlayer = remember { FocusRequester() }
LaunchedEffect(Unit) {
episodePlayer.requestFocus()
}
BackgroundContainer(
playerEpisode = playerEpisode,
modifier = modifier,
contentAlignment = Alignment.Center,
) {
EpisodePlayer(
playerEpisode = playerEpisode,
isPlaying = isPlaying,
timeElapsed = timeElapsed,
play = play,
pause = pause,
previous = previous,
next = next,
skip = skip,
rewind = rewind,
enqueue = enqueue,
showDetails = showDetails,
focusRequester = episodePlayer,
modifier = Modifier
.padding(JetcasterAppDefaults.overScanMargin.player.intoPaddingValues()),
)
PlayerQueueOverlay(
playerEpisodeList = queue,
onSelected = playEpisode,
modifier = Modifier.fillMaxSize(),
contentPadding = JetcasterAppDefaults.overScanMargin.player.copy(top = 0.dp)
.intoPaddingValues(),
offset = DpOffset(0.dp, 136.dp),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EpisodePlayer(
playerEpisode: PlayerEpisode,
isPlaying: Boolean,
timeElapsed: Duration,
play: () -> Unit,
pause: () -> Unit,
previous: () -> Unit,
next: () -> Unit,
skip: () -> Unit,
rewind: () -> Unit,
enqueue: (PlayerEpisode) -> Unit,
showDetails: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
bringIntoViewRequester: BringIntoViewRequester = remember { BringIntoViewRequester() },
coroutineScope: CoroutineScope = rememberCoroutineScope(),
focusRequester: FocusRequester = remember { FocusRequester() },
) {
Column(
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.section),
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged {
if (it.hasFocus) {
coroutineScope.launch {
bringIntoViewRequester.bringIntoView()
}
}
}
.then(modifier),
) {
EpisodeDetails(
playerEpisode = playerEpisode,
content = {},
controls = {
EpisodeControl(
showDetails = { showDetails(playerEpisode) },
enqueue = { enqueue(playerEpisode) },
)
},
)
PlayerControl(
isPlaying = isPlaying,
timeElapsed = timeElapsed,
length = playerEpisode.duration,
play = play,
pause = pause,
previous = previous,
next = next,
skip = skip,
rewind = rewind,
focusRequester = focusRequester,
)
}
}
@Composable
private fun EpisodeControl(showDetails: () -> Unit, enqueue: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
) {
EnqueueButton(
onClick = enqueue,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.default.intoDpSize()),
)
InfoButton(
onClick = showDetails,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.default.intoDpSize()),
)
}
}
@Composable
private fun PlayerControl(
isPlaying: Boolean,
timeElapsed: Duration,
length: Duration?,
play: () -> Unit,
pause: () -> Unit,
previous: () -> Unit,
next: () -> Unit,
skip: () -> Unit,
rewind: () -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
val playPauseButton = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
JetcasterAppDefaults.gap.default,
Alignment.CenterHorizontally,
),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
playPauseButton.requestFocus()
}
}
.focusable(),
) {
PreviousButton(
onClick = previous,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()),
)
RewindButton(
onClick = rewind,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()),
)
PlayPauseButton(
isPlaying = isPlaying,
onClick = {
if (isPlaying) {
pause()
} else {
play()
}
},
modifier = Modifier
.size(JetcasterAppDefaults.iconButtonSize.large.intoDpSize())
.focusRequester(playPauseButton),
)
SkipButton(
onClick = skip,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()),
)
NextButton(
onClick = next,
modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()),
)
}
if (length != null) {
ElapsedTimeIndicator(timeElapsed, length, skip, rewind)
}
}
}
@Composable
private fun ElapsedTimeIndicator(
timeElapsed: Duration,
length: Duration,
skip: () -> Unit,
rewind: () -> Unit,
modifier: Modifier = Modifier,
knobSize: Dp = 8.dp,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.tiny),
) {
ElapsedTime(timeElapsed = timeElapsed, length = length)
Seekbar(
timeElapsed = timeElapsed,
length = length,
knobSize = knobSize,
onMoveLeft = rewind,
onMoveRight = skip,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun ElapsedTime(
timeElapsed: Duration,
length: Duration,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodySmall,
) {
val elapsed =
stringResource(
R.string.minutes_seconds,
timeElapsed.toMinutes(),
timeElapsed.toSeconds() % 60,
)
val l =
stringResource(R.string.minutes_seconds, length.toMinutes(), length.toSeconds() % 60)
Text(
text = stringResource(R.string.elapsed_time, elapsed, l),
style = style,
modifier = modifier,
)
}
@Composable
private fun NoEpisodeInQueue(
backToHome: () -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(contentAlignment = Alignment.Center, modifier = modifier) {
Column {
Text(
text = stringResource(R.string.display_nothing_in_queue),
style = MaterialTheme.typography.displayMedium,
)
Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph))
Text(text = stringResource(R.string.message_nothing_in_queue))
Button(onClick = backToHome, modifier = Modifier.focusRequester(focusRequester)) {
Text(text = stringResource(R.string.label_back_to_home))
}
}
}
}
@Composable
private fun PlayerQueueOverlay(
playerEpisodeList: EpisodeList,
onSelected: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal =
Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
contentPadding: PaddingValues = PaddingValues(),
contentAlignment: Alignment = Alignment.BottomStart,
scrim: DrawScope.() -> Unit = {
val brush = Brush.verticalGradient(
listOf(Color.Transparent, Color.Black),
)
drawRect(brush, blendMode = BlendMode.Multiply)
},
offset: DpOffset = DpOffset.Zero,
) {
var hasFocus by remember { mutableStateOf(false) }
val actualOffset = if (hasFocus) {
DpOffset.Zero
} else {
offset
}
Box(
modifier = modifier.drawWithCache {
onDrawBehind {
if (hasFocus) {
scrim()
}
}
},
contentAlignment = contentAlignment,
) {
EpisodeRow(
playerEpisodeList = playerEpisodeList,
onSelected = onSelected,
horizontalArrangement = horizontalArrangement,
contentPadding = contentPadding,
modifier = Modifier
.offset(actualOffset.x, actualOffset.y)
.onFocusChanged { hasFocus = it.hasFocus },
)
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
internal fun rememberPlayer(context: Context) = remember {
ExoPlayer.Builder(context)
.setSeekForwardIncrementMs(10 * 1000)
.setSeekBackIncrementMs(10 * 1000)
.setMediaSourceFactory(ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)))
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)
.build()
.apply {
playWhenReady = true
repeatMode = REPEAT_MODE_ALL
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt
================================================
/*
* 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.tv.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.EpisodePlayerState
import com.example.jetcaster.core.player.model.PlayerEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Duration
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class PlayerScreenViewModel @Inject constructor(private val episodePlayer: EpisodePlayer) : ViewModel() {
val uiStateFlow = episodePlayer.playerState.map {
if (it.currentEpisode == null && it.queue.isEmpty()) {
PlayerScreenUiState.NoEpisodeInQueue
} else {
PlayerScreenUiState.Ready(it)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
PlayerScreenUiState.Loading,
)
private val skipAmount = Duration.ofSeconds(10L)
fun play() {
if (episodePlayer.playerState.value.currentEpisode == null) {
episodePlayer.next()
}
episodePlayer.play()
}
fun play(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
fun pause() = episodePlayer.pause()
fun next() = episodePlayer.next()
fun previous() = episodePlayer.previous()
fun skip() {
episodePlayer.advanceBy(skipAmount)
}
fun rewind() {
episodePlayer.rewindBy(skipAmount)
}
fun enqueue(playerEpisode: PlayerEpisode) {
episodePlayer.addToQueue(playerEpisode)
}
}
sealed interface PlayerScreenUiState {
data object Loading : PlayerScreenUiState
data class Ready(val playerState: EpisodePlayerState) : PlayerScreenUiState
data object NoEpisodeInQueue : PlayerScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreen.kt
================================================
/*
* 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.tv.ui.podcast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.model.EpisodeList
import com.example.jetcaster.tv.ui.component.BackgroundContainer
import com.example.jetcaster.tv.ui.component.ButtonWithIcon
import com.example.jetcaster.tv.ui.component.EnqueueButton
import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration
import com.example.jetcaster.tv.ui.component.ErrorState
import com.example.jetcaster.tv.ui.component.InfoButton
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.component.PlayButton
import com.example.jetcaster.tv.ui.component.Thumbnail
import com.example.jetcaster.tv.ui.component.TwoColumn
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun PodcastDetailsScreen(
backToHomeScreen: () -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
showEpisodeDetails: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
podcastDetailsScreenViewModel: PodcastDetailsScreenViewModel = hiltViewModel(),
) {
val uiState by podcastDetailsScreenViewModel.uiStateFlow.collectAsState()
when (val s = uiState) {
PodcastScreenUiState.Loading -> Loading(modifier = modifier)
PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier)
is PodcastScreenUiState.Ready -> PodcastDetailsWithBackground(
podcastInfo = s.podcastInfo,
episodeList = s.episodeList,
isSubscribed = s.isSubscribed,
subscribe = podcastDetailsScreenViewModel::subscribe,
unsubscribe = podcastDetailsScreenViewModel::unsubscribe,
playEpisode = {
podcastDetailsScreenViewModel.play(it)
playEpisode(it)
},
enqueue = podcastDetailsScreenViewModel::enqueue,
showEpisodeDetails = showEpisodeDetails,
)
}
}
@Composable
private fun PodcastDetailsWithBackground(
podcastInfo: PodcastInfo,
episodeList: EpisodeList,
isSubscribed: Boolean,
subscribe: (PodcastInfo, Boolean) -> Unit,
unsubscribe: (PodcastInfo, Boolean) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
showEpisodeDetails: (PlayerEpisode) -> Unit,
enqueue: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
BackgroundContainer(podcastInfo = podcastInfo, modifier = modifier) {
PodcastDetails(
podcastInfo = podcastInfo,
episodeList = episodeList,
isSubscribed = isSubscribed,
subscribe = subscribe,
unsubscribe = unsubscribe,
playEpisode = playEpisode,
focusRequester = focusRequester,
showEpisodeDetails = showEpisodeDetails,
enqueue = enqueue,
modifier = Modifier
.fillMaxSize(),
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PodcastDetails(
podcastInfo: PodcastInfo,
episodeList: EpisodeList,
isSubscribed: Boolean,
subscribe: (PodcastInfo, Boolean) -> Unit,
unsubscribe: (PodcastInfo, Boolean) -> Unit,
playEpisode: (PlayerEpisode) -> Unit,
showEpisodeDetails: (PlayerEpisode) -> Unit,
enqueue: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
TwoColumn(
modifier = modifier,
horizontalArrangement =
Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn),
first = {
PodcastInfo(
podcastInfo = podcastInfo,
isSubscribed = isSubscribed,
subscribe = subscribe,
unsubscribe = unsubscribe,
modifier = Modifier
.weight(0.3f)
.padding(
JetcasterAppDefaults.overScanMargin.podcast.copy(end = 0.dp)
.intoPaddingValues(),
),
)
},
second = {
PodcastEpisodeList(
episodeList = episodeList,
playEpisode = { playEpisode(it) },
showDetails = showEpisodeDetails,
enqueue = enqueue,
modifier = Modifier
.focusRequester(focusRequester)
.focusRestorer()
.weight(0.7f),
)
},
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
private fun PodcastInfo(
podcastInfo: PodcastInfo,
isSubscribed: Boolean,
subscribe: (PodcastInfo, Boolean) -> Unit,
unsubscribe: (PodcastInfo, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Thumbnail(podcastInfo = podcastInfo)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = podcastInfo.author,
style = MaterialTheme.typography.bodySmall,
)
Text(
text = podcastInfo.title,
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = podcastInfo.description,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
ToggleSubscriptionButton(
podcastInfo,
isSubscribed,
subscribe,
unsubscribe,
modifier = Modifier
.padding(top = JetcasterAppDefaults.gap.podcastRow),
)
}
}
@Composable
private fun ToggleSubscriptionButton(
podcastInfo: PodcastInfo,
isSubscribed: Boolean,
subscribe: (PodcastInfo, Boolean) -> Unit,
unsubscribe: (PodcastInfo, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val icon = if (isSubscribed) {
Icons.Default.Remove
} else {
Icons.Default.Add
}
val label = if (isSubscribed) {
stringResource(R.string.label_unsubscribe)
} else {
stringResource(R.string.label_subscribe)
}
val action = if (isSubscribed) {
unsubscribe
} else {
subscribe
}
ButtonWithIcon(
label = label,
icon = icon,
onClick = { action(podcastInfo, isSubscribed) },
scale = ButtonDefaults.scale(scale = 1f),
modifier = modifier,
)
}
@Composable
private fun PodcastEpisodeList(
episodeList: EpisodeList,
playEpisode: (PlayerEpisode) -> Unit,
showDetails: (PlayerEpisode) -> Unit,
enqueue: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow),
modifier = modifier,
contentPadding = JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues(),
) {
items(episodeList) {
EpisodeListItem(
playerEpisode = it,
onEpisodeSelected = { playEpisode(it) },
onInfoClicked = { showDetails(it) },
onEnqueueClicked = { enqueue(it) },
)
}
}
}
@Composable
private fun EpisodeListItem(
playerEpisode: PlayerEpisode,
onEpisodeSelected: () -> Unit,
onInfoClicked: () -> Unit,
onEnqueueClicked: () -> Unit,
modifier: Modifier = Modifier,
borderWidth: Dp = 2.dp,
cornerRadius: Dp = 12.dp,
) {
var hasFocus by remember {
mutableStateOf(false)
}
val shape = RoundedCornerShape(cornerRadius)
val backgroundColor = if (hasFocus) {
MaterialTheme.colorScheme.surface
} else {
Color.Transparent
}
val borderColor = if (hasFocus) {
MaterialTheme.colorScheme.border
} else {
Color.Transparent
}
val elevation = if (hasFocus) {
10.dp
} else {
0.dp
}
EpisodeListItemContentLayer(
playerEpisode = playerEpisode,
onEpisodeSelected = onEpisodeSelected,
onInfoClicked = onInfoClicked,
onEnqueueClicked = onEnqueueClicked,
modifier = modifier
.clip(shape)
.onFocusChanged {
hasFocus = it.hasFocus
}
.border(borderWidth, borderColor, shape)
.background(backgroundColor)
.shadow(elevation, shape)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 16.dp),
)
}
@Composable
private fun EpisodeListItemContentLayer(
playerEpisode: PlayerEpisode,
onEpisodeSelected: () -> Unit,
onInfoClicked: () -> Unit,
onEnqueueClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val duration = playerEpisode.duration
val playButton = remember { FocusRequester() }
Box(
contentAlignment = Alignment.CenterStart,
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.tiny),
) {
EpisodeTitle(playerEpisode)
Row(
horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.default),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = JetcasterAppDefaults.gap.paragraph),
) {
PlayButton(
onClick = onEpisodeSelected,
modifier = Modifier.focusRequester(playButton),
)
if (duration != null) {
EpisodeDataAndDuration(playerEpisode.published, duration)
}
Spacer(modifier = Modifier.weight(1f))
EnqueueButton(onClick = onEnqueueClicked)
InfoButton(onClick = onInfoClicked)
}
}
}
}
@Composable
private fun EpisodeTitle(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) {
Text(
text = playerEpisode.title,
style = MaterialTheme.typography.titleLarge,
modifier = modifier,
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreenViewModel.kt
================================================
/*
* 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.tv.ui.podcast
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.tv.model.EpisodeList
import com.example.jetcaster.tv.ui.Screen
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class PodcastDetailsScreenViewModel @Inject constructor(
handle: SavedStateHandle,
private val podcastStore: PodcastStore,
episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
private val podcastUri = handle.get<String>(Screen.Podcast.PARAMETER_NAME)
@OptIn(ExperimentalCoroutinesApi::class)
private val podcastFlow =
handle.getStateFlow<String?>(Screen.Podcast.PARAMETER_NAME, null).flatMapLatest {
if (it != null) {
podcastStore.podcastWithUri(it)
} else {
flowOf(null)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private val episodeListFlow = podcastFlow.flatMapLatest {
if (it != null) {
episodeStore.episodesInPodcast(it.uri)
} else {
flowOf(emptyList())
}
}.map { list ->
EpisodeList(list.map { it.toPlayerEpisode() })
}
private val subscribedPodcastListFlow =
podcastStore.followedPodcastsSortedByLastEpisode()
val uiStateFlow = combine(
podcastFlow,
episodeListFlow,
subscribedPodcastListFlow,
) { podcast, episodeList, subscribedPodcastList ->
if (podcast != null) {
val isSubscribed = subscribedPodcastList.any { it.podcast.uri == podcastUri }
PodcastScreenUiState.Ready(podcast.asExternalModel(), episodeList, isSubscribed)
} else {
PodcastScreenUiState.Error
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
PodcastScreenUiState.Loading,
)
fun subscribe(podcastInfo: PodcastInfo, isSubscribed: Boolean) {
if (!isSubscribed) {
viewModelScope.launch {
podcastStore.togglePodcastFollowed(podcastInfo.uri)
}
}
}
fun unsubscribe(podcastInfo: PodcastInfo, isSubscribed: Boolean) {
if (isSubscribed) {
viewModelScope.launch {
podcastStore.togglePodcastFollowed(podcastInfo.uri)
}
}
}
fun play(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
fun enqueue(playerEpisode: PlayerEpisode) {
episodePlayer.addToQueue(playerEpisode)
}
}
sealed interface PodcastScreenUiState {
data object Loading : PodcastScreenUiState
data object Error : PodcastScreenUiState
data class Ready(val podcastInfo: PodcastInfo, val episodeList: EpisodeList, val isSubscribed: Boolean) : PodcastScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt
================================================
/*
* 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.tv.ui.profile
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.jetcaster.tv.ui.component.NotAvailableFeature
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
NotAvailableFeature(modifier = modifier)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt
================================================
/*
* 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.tv.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.FilterChip
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.tv.R
import com.example.jetcaster.tv.model.CategorySelectionList
import com.example.jetcaster.tv.model.PodcastList
import com.example.jetcaster.tv.ui.component.Loading
import com.example.jetcaster.tv.ui.component.PodcastCard
import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
@Composable
fun SearchScreen(
onPodcastSelected: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
searchScreenViewModel: SearchScreenViewModel = hiltViewModel(),
) {
val uiState by searchScreenViewModel.uiStateFlow.collectAsState()
when (val s = uiState) {
SearchScreenUiState.Loading -> Loading(modifier = modifier)
is SearchScreenUiState.Ready -> Ready(
keyword = s.keyword,
categorySelectionList = s.categorySelectionList,
onKeywordInput = searchScreenViewModel::setKeyword,
onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList,
onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList,
modifier = modifier,
)
is SearchScreenUiState.HasResult -> HasResult(
keyword = s.keyword,
categorySelectionList = s.categorySelectionList,
podcastList = s.result,
onKeywordInput = searchScreenViewModel::setKeyword,
onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList,
onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList,
onPodcastSelected = onPodcastSelected,
modifier = modifier,
)
}
}
@Composable
private fun Ready(
keyword: String,
categorySelectionList: CategorySelectionList,
onKeywordInput: (String) -> Unit,
onCategorySelected: (CategoryInfo) -> Unit,
onCategoryUnselected: (CategoryInfo) -> Unit,
modifier: Modifier = Modifier,
) {
Controls(
keyword = keyword,
categorySelectionList = categorySelectionList,
onKeywordInput = onKeywordInput,
onCategorySelected = onCategorySelected,
onCategoryUnselected = onCategoryUnselected,
modifier = modifier,
toRequestFocus = true,
)
}
@Composable
private fun HasResult(
keyword: String,
categorySelectionList: CategorySelectionList,
podcastList: PodcastList,
onKeywordInput: (String) -> Unit,
onCategorySelected: (CategoryInfo) -> Unit,
onCategoryUnselected: (CategoryInfo) -> Unit,
onPodcastSelected: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
) {
SearchResult(
podcastList = podcastList,
onPodcastSelected = onPodcastSelected,
header = {
Controls(
keyword = keyword,
categorySelectionList = categorySelectionList,
onKeywordInput = onKeywordInput,
onCategorySelected = onCategorySelected,
onCategoryUnselected = onCategoryUnselected,
)
},
modifier = modifier,
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Controls(
keyword: String,
categorySelectionList: CategorySelectionList,
onKeywordInput: (String) -> Unit,
onCategorySelected: (CategoryInfo) -> Unit,
onCategoryUnselected: (CategoryInfo) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
toRequestFocus: Boolean = false,
) {
LaunchedEffect(toRequestFocus) {
if (toRequestFocus) {
focusRequester.requestFocus()
}
}
Column(
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item),
modifier = modifier,
) {
KeywordInput(
keyword = keyword,
onKeywordInput = onKeywordInput,
)
CategorySelection(
categorySelectionList = categorySelectionList,
onCategorySelected = onCategorySelected,
onCategoryUnselected = onCategoryUnselected,
modifier = Modifier
.focusRestorer()
.focusRequester(focusRequester),
)
}
}
@Composable
private fun KeywordInput(keyword: String, onKeywordInput: (String) -> Unit, modifier: Modifier = Modifier) {
val textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant)
BasicTextField(
value = keyword,
onValueChange = onKeywordInput,
textStyle = textStyle,
cursorBrush = cursorBrush,
modifier = modifier,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(percent = 50),
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Search,
contentDescription = stringResource(R.string.label_search),
modifier = Modifier.padding(end = 12.dp),
)
innerTextField()
}
}
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun CategorySelection(
categorySelectionList: CategorySelectionList,
onCategorySelected: (CategoryInfo) -> Unit,
onCategoryUnselected: (CategoryInfo) -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip),
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip),
) {
categorySelectionList.forEach {
FilterChip(
selected = it.isSelected,
onClick = {
if (it.isSelected) {
onCategoryUnselected(it.categoryInfo)
} else {
onCategorySelected(it.categoryInfo)
}
},
) {
Text(text = it.categoryInfo.name)
}
}
}
}
@Composable
private fun SearchResult(
podcastList: PodcastList,
onPodcastSelected: (PodcastInfo) -> Unit,
header: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement =
Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow),
verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow),
modifier = modifier,
) {
item(span = { GridItemSpan(maxLineSpan) }) {
header()
}
items(podcastList) {
PodcastCard(podcastInfo = it, onClick = { onPodcastSelected(it) })
}
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt
================================================
/*
* 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.tv.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
import com.example.jetcaster.core.model.CategoryInfo
import com.example.jetcaster.core.model.asExternalModel
import com.example.jetcaster.tv.model.CategoryInfoList
import com.example.jetcaster.tv.model.CategorySelection
import com.example.jetcaster.tv.model.CategorySelectionList
import com.example.jetcaster.tv.model.PodcastList
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class SearchScreenViewModel @Inject constructor(
private val podcastsRepository: PodcastsRepository,
private val podcastStore: PodcastStore,
categoryStore: CategoryStore,
) : ViewModel() {
private val keywordFlow = MutableStateFlow("")
private val selectedCategoryListFlow = MutableStateFlow<List<CategoryInfo>>(emptyList())
private val categoryInfoListFlow =
categoryStore.categoriesSortedByPodcastCount().map(CategoryInfoList::from)
private val searchConditionFlow =
combine(
keywordFlow,
selectedCategoryListFlow,
categoryInfoListFlow,
) { keyword, selectedCategories, categories ->
val selected = selectedCategories.ifEmpty {
categories
}
SearchCondition(keyword, selected)
}
@OptIn(ExperimentalCoroutinesApi::class)
private val searchResultFlow = searchConditionFlow.flatMapLatest {
podcastStore.searchPodcastByTitleAndCategories(
it.keyword,
it.selectedCategories.intoCategoryList(),
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptyList(),
)
private val categorySelectionFlow =
combine(
categoryInfoListFlow,
selectedCategoryListFlow,
) { categoryList, selectedCategories ->
val list = categoryList.map {
CategorySelection(it, selectedCategories.contains(it))
}
CategorySelectionList(list)
}
val uiStateFlow =
combine(
keywordFlow,
categorySelectionFlow,
searchResultFlow,
) { keyword, categorySelection, result ->
val podcastList = result.map { it.asExternalModel() }
when {
result.isEmpty() -> SearchScreenUiState.Ready(keyword, categorySelection)
else -> SearchScreenUiState.HasResult(keyword, categorySelection, podcastList)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
SearchScreenUiState.Loading,
)
fun setKeyword(keyword: String) {
keywordFlow.value = keyword
}
fun addCategoryToSelectedCategoryList(category: CategoryInfo) {
val list = selectedCategoryListFlow.value
if (!list.contains(category)) {
selectedCategoryListFlow.value = list + listOf(category)
}
}
fun removeCategoryFromSelectedCategoryList(category: CategoryInfo) {
val list = selectedCategoryListFlow.value
if (list.contains(category)) {
val mutable = list.toMutableList()
mutable.remove(category)
selectedCategoryListFlow.value = mutable.toList()
}
}
init {
viewModelScope.launch {
podcastsRepository.updatePodcasts(false)
}
}
}
private data class SearchCondition(val keyword: String, val selectedCategories: CategoryInfoList) {
constructor(keyword: String, categoryInfoList: List<CategoryInfo>) : this(
keyword,
CategoryInfoList(categoryInfoList),
)
}
sealed interface SearchScreenUiState {
data object Loading : SearchScreenUiState
data class Ready(val keyword: String, val categorySelectionList: CategorySelectionList) : SearchScreenUiState
data class HasResult(val keyword: String, val categorySelectionList: CategorySelectionList, val result: PodcastList) :
SearchScreenUiState
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt
================================================
/*
* 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.tv.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.jetcaster.tv.ui.component.NotAvailableFeature
@Composable
fun SettingsScreen(modifier: Modifier = Modifier) {
NotAvailableFeature(modifier = modifier)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt
================================================
/*
* 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.tv.ui.theme
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
import com.example.jetcaster.designsystem.theme.backgroundDark
import com.example.jetcaster.designsystem.theme.backgroundLight
import com.example.jetcaster.designsystem.theme.errorContainerDark
import com.example.jetcaster.designsystem.theme.errorContainerLight
import com.example.jetcaster.designsystem.theme.errorDark
import com.example.jetcaster.designsystem.theme.errorLight
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight
import com.example.jetcaster.designsystem.theme.inversePrimaryDark
import com.example.jetcaster.designsystem.theme.inversePrimaryLight
import com.example.jetcaster.designsystem.theme.inverseSurfaceDark
import com.example.jetcaster.designsystem.theme.inverseSurfaceLight
import com.example.jetcaster.designsystem.theme.onBackgroundDark
import com.example.jetcaster.designsystem.theme.onBackgroundLight
import com.example.jetcaster.designsystem.theme.onErrorContainerDark
import com.example.jetcaster.designsystem.theme.onErrorContainerLight
import com.example.jetcaster.designsystem.theme.onErrorDark
import com.example.jetcaster.designsystem.theme.onErrorLight
import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark
import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight
import com.example.jetcaster.designsystem.theme.onPrimaryDark
import com.example.jetcaster.designsystem.theme.onPrimaryLight
import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark
import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight
import com.example.jetcaster.designsystem.theme.onSecondaryDark
import com.example.jetcaster.designsystem.theme.onSecondaryLight
import com.example.jetcaster.designsystem.theme.onSurfaceDark
import com.example.jetcaster.designsystem.theme.onSurfaceLight
import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark
import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight
import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark
import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight
import com.example.jetcaster.designsystem.theme.onTertiaryDark
import com.example.jetcaster.designsystem.theme.onTertiaryLight
import com.example.jetcaster.designsystem.theme.outlineDark
import com.example.jetcaster.designsystem.theme.outlineLight
import com.example.jetcaster.designsystem.theme.outlineVariantDark
import com.example.jetcaster.designsystem.theme.outlineVariantLight
import com.example.jetcaster.designsystem.theme.primaryContainerDark
import com.example.jetcaster.designsystem.theme.primaryContainerLight
import com.example.jetcaster.designsystem.theme.primaryDark
import com.example.jetcaster.designsystem.theme.primaryLight
import com.example.jetcaster.designsystem.theme.scrimDark
import com.example.jetcaster.designsystem.theme.scrimLight
import com.example.jetcaster.designsystem.theme.secondaryContainerDark
import com.example.jetcaster.designsystem.theme.secondaryContainerLight
import com.example.jetcaster.designsystem.theme.secondaryDark
import com.example.jetcaster.designsystem.theme.secondaryLight
import com.example.jetcaster.designsystem.theme.surfaceDark
import com.example.jetcaster.designsystem.theme.surfaceLight
import com.example.jetcaster.designsystem.theme.surfaceVariantDark
import com.example.jetcaster.designsystem.theme.surfaceVariantLight
import com.example.jetcaster.designsystem.theme.tertiaryContainerDark
import com.example.jetcaster.designsystem.theme.tertiaryContainerLight
import com.example.jetcaster.designsystem.theme.tertiaryDark
import com.example.jetcaster.designsystem.theme.tertiaryLight
val colorSchemeForDarkMode = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
border = outlineDark,
borderVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
)
// Todo: specify surfaceTint
val colorSchemeForLightMode = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
border = outlineLight,
borderVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
)
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt
================================================
/*
* 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.tv.ui.theme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
internal data object JetcasterAppDefaults {
val overScanMargin = OverScanMarginSettings()
val gap = GapSettings()
val cardWidth = CardWidth()
val padding = PaddingSettings()
val thumbnailSize = ThumbnailSize()
val iconButtonSize: IconButtonSize = IconButtonSize()
}
internal data class OverScanMarginSettings(
val default: OverScanMargin = OverScanMargin(),
val catalog: OverScanMargin = OverScanMargin(end = 0.dp),
val episode: OverScanMargin = OverScanMargin(start = 80.dp, end = 80.dp),
val drawer: OverScanMargin = OverScanMargin(start = 16.dp, end = 16.dp),
val podcast: OverScanMargin = OverScanMargin(
top = 40.dp,
bottom = 40.dp,
start = 80.dp,
end = 80.dp,
),
val player: OverScanMargin = OverScanMargin(
top = 40.dp,
bottom = 40.dp,
start = 80.dp,
end = 80.dp,
),
)
internal data class OverScanMargin(val top: Dp = 24.dp, val bottom: Dp = 24.dp, val start: Dp = 48.dp, val end: Dp = 48.dp) {
fun intoPaddingValues(): PaddingValues {
return PaddingValues(start, top, end, bottom)
}
}
internal data class CardWidth(val large: Dp = 268.dp, val medium: Dp = 196.dp, val small: Dp = 124.dp)
internal data class ThumbnailSize(
val episodeDetails: DpSize = DpSize(266.dp, 266.dp),
val podcast: DpSize = DpSize(196.dp, 196.dp),
val episode: DpSize = DpSize(124.dp, 124.dp),
)
internal data class PaddingSettings(
val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp),
val podcastRowContentPadding: PaddingValues = PaddingValues(horizontal = 5.dp),
val episodeRowContentPadding: PaddingValues = PaddingValues(horizontal = 5.dp),
)
internal data class GapSettings(
val tiny: Dp = 4.dp,
val small: Dp = tiny * 2,
val default: Dp = small * 2,
val medium: Dp = default + tiny,
val large: Dp = medium * 2,
val chip: Dp = small,
val episodeRow: Dp = medium,
val item: Dp = default,
val paragraph: Dp = default,
val podcastRow: Dp = medium,
val section: Dp = large,
val twoColumn: Dp = large,
)
internal data class IconButtonSize(
val default: Radius = Radius(14.dp),
val medium: Radius = Radius(20.dp),
val large: Radius = Radius(28.dp),
)
internal data class Radius(private val value: Dp) {
private fun diameter(): Dp {
return value * 2
}
fun intoDpSize(): DpSize {
val d = diameter()
return DpSize(d, d)
}
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt
================================================
/*
* 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.tv.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.tv.material3.MaterialTheme
@Composable
fun JetcasterTheme(isInDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme = if (isInDarkTheme) {
colorSchemeForDarkMode
} else {
colorSchemeForLightMode
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
================================================
FILE: Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt
================================================
/*
* 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.tv.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Typography
import com.example.jetcaster.designsystem.theme.Montserrat
// Set of Material typography styles to start with
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
),
displayMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 42.sp,
lineHeight = 52.sp,
),
displaySmall = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
),
headlineLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineSmall = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
titleSmall = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 11.sp,
lineHeight = 16.sp,
),
bodyLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
bodySmall = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
),
)
================================================
FILE: Jetcaster/tv/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>
================================================
FILE: Jetcaster/tv/src/main/res/values/strings.xml
================================================
<!--
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
http://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.
-->
<resources>
<string name="app_name">JetCaster</string>
<string name="message_not_available_feature">This feature is not available yet.</string>
<string name="message_loading">Loading</string>
<string name="display_no_subscribed_podcast">Let\'s discover the podcasts!</string>
<string name="message_no_subscribed_podcast">You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them!</string>
<string name="display_error_state">Something wrong happened</string>
<string name="display_nothing_in_queue">No episode in the queue</string>
<string name="message_nothing_in_queue">Discover the Podcast you want to listen to</string>
<string name="section_podcast">Podcast</string>
<string name="section_latest_episodes">Latest Episodes</string>
<string name="label_subscribe">Subscribe</string>
<string name="label_unsubscribe">Subscribed</string>
<string name="label_info">Info</string>
<string name="label_play">Play</string>
<string name="label_pause">Pause</string>
<string name="label_skip">Skip 10 seconds</string>
<string name="label_rewind">Rewind 10 seconds</string>
<string name="label_next_episode">Play the next episode</string>
<string name="label_previous_episode">Play the previous episode</string>
<string name="label_listen">Listen</string>
<string name="label_podcast">Podcasts</string>
<string name="label_episode">Episodes</string>
<string name="label_latest_episode">Latest Episodes</string>
<string name="label_navigate_to_discover">Discover the podcasts</string>
<string name="label_back_to_home">Back to Home</string>
<string name="label_search">Search podcasts by keyword</string>
<string name="label_add_playlist">Add to playlist</string>
<string name="updated_longer">Updated a while ago</string>
<plurals name="updated_weeks_ago">
<item quantity="one">Updated %d week ago</item>
<item quantity="other">Updated %d weeks ago</item>
</plurals>
<plurals name="updated_days_ago">
<item quantity="one">Updated yesterday</item>
<item quantity="other">Updated %d days ago</item>
</plurals>
<string name="updated_today">Updated today</string>
<string name="episode_date_duration">%1$s &#8226; %2$d mins</string>
<string name="elapsed_time">%1$s &#8226; %2$s</string>
<string name="minutes_seconds">%1$02d:%2$02d</string>
</resources>
================================================
FILE: Jetcaster/tv/src/main/res/values/themes.xml
================================================
<!--
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
http://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.
-->
<resources>
<style name="Theme.Jetcaster" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>
================================================
FILE: Jetcaster/wear/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 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
http://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.
-->
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.type.watch" />
<application
android:name=".JetcasterWearApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<!--
Set to true if your app is Standalone, that is, it does not require the handheld
app to run.
-->
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.App.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt
================================================
/*
* 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
import android.app.Application
import android.os.StrictMode
import coil.ImageLoader
import coil.ImageLoaderFactory
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class JetcasterWearApplication :
Application(),
ImageLoaderFactory {
@Inject lateinit var imageLoader: ImageLoader
override fun onCreate() {
super.onCreate()
setStrictMode()
}
private fun setStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build(),
)
}
override fun newImageLoader(): ImageLoader = imageLoader
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt
================================================
/*
* Copyright 2022-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.
* 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
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.navigation.NavHostController
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
lateinit var navController: NavHostController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
navController = rememberSwipeDismissableNavController()
WearApp(navController)
}
}
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt
================================================
/*
* Copyright 2024-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.
* 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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.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
import com.example.jetcaster.ui.JetcasterNavController.navigateToEpisode
import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode
import com.example.jetcaster.ui.JetcasterNavController.navigateToPodcastDetails
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.PodcastDetails
import com.example.jetcaster.ui.UpNext
import com.example.jetcaster.ui.YourPodcasts
import com.example.jetcaster.ui.episode.EpisodeScreen
import com.example.jetcaster.ui.latest_episodes.LatestEpisodesScreen
import com.example.jetcaster.ui.library.LibraryScreen
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.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
import com.google.android.horologist.media.ui.material3.screens.playerlibrarypager.PlayerLibraryPagerScreen
@Composable
fun WearApp(navController: NavHostController) {
val navHostState = rememberSwipeDismissableNavHostState()
val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory)
WearAppTheme {
AppScaffold {
SwipeDismissableNavHost(
startDestination = NavigationScreens.Player.navRoute,
navController = navController,
modifier = Modifier.background(Color.Transparent),
state = navHostState,
) {
composable(
route = NavigationScreens.Player.navRoute,
arguments = NavigationScreens.Player.arguments,
deepLinks = NavigationScreens.Player.deepLinks(""),
) {
val volumeState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
PlayerLibraryPagerScreen(
pagerState = pagerState,
volumeUiState = { volumeState },
displayVolumeIndicatorEvents = volumeViewModel.displayIndicatorEvents,
playerScreen = {
PlayerScreen(
modifier = Modifier.fillMaxSize(),
volumeViewModel = volumeViewModel,
onVolumeClick = {
navController.navigateToVolume()
},
)
},
libraryScreen = {
LibraryScreen(
onLatestEpisodeClick = { navController.navigateToLatestEpisode() },
onYourPodcastClick = { navController.navigateToYourPodcast() },
onUpNextClick = { navController.navigateToUpNext() },
)
},
backStack = it,
)
}
composable(
route = NavigationScreens.Volume.navRoute,
arguments = NavigationScreens.Volume.arguments,
deepLinks = NavigationScreens.Volume.deepLinks(""),
) {
ScreenScaffold(timeText = {}) {
VolumeScreen(volumeViewModel = volumeViewModel)
}
}
composable(
route = LatestEpisodes.navRoute,
) {
LatestEpisodesScreen(
onPlayButtonClick = {
navController.navigateToPlayer()
},
onDismiss = { navController.popBackStack() },
)
}
composable(route = YourPodcasts.navRoute) {
PodcastsScreen(
onPodcastsItemClick = { navController.navigateToPodcastDetails(it.uri) },
onDismiss = { navController.popBackStack() },
)
}
composable(route = PodcastDetails.navRoute) {
PodcastDetailsScreen(
onPlayButtonClick = {
navController.navigateToPlayer()
},
onEpisodeItemClick = { navController.navigateToEpisode(it.uri) },
onDismiss = { navController.popBackStack() },
)
}
composable(route = UpNext.navRoute) {
QueueScreen(
onPlayButtonClick = {
navController.navigateToPlayer()
},
onEpisodeItemClick = { navController.navigateToPlayer() },
onDismiss = {
navController.popBackStack()
navController.navigateToYourPodcast()
},
)
}
composable(route = Episode.navRoute) {
EpisodeScreen(
onPlayButtonClick = {
navController.navigateToPlayer()
},
onDismiss = {
navController.popBackStack()
navController.navigateToYourPodcast()
},
)
}
}
}
}
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/theme/ColorScheme.kt
================================================
/*
* 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,
primaryDim = primaryDark,
onPrimary = Color(0xFF542104),
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
secondaryDim = 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,
)
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Shape.kt
================================================
/*
* 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.
* 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.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Shapes
val Shapes = Shapes(
medium = RoundedCornerShape(16.dp),
)
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt
================================================
/*
* 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.material3.Typography
import com.example.jetcaster.designsystem.theme.JetcasterTypography
// Set of Material typography styles to start with
val Typography = Typography(
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,
)
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt
================================================
/*
* 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.runtime.Composable
import androidx.wear.compose.material3.MaterialTheme
@Composable
fun WearAppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = wearColorPalette,
typography = Typography,
shapes = Shapes,
content = content,
)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt
================================================
/*
* 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
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.NavigationScreens
/**
* 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"
}
override val arguments: List<NamedNavArgument>
get() = listOf(
navArgument(PODCAST_URI) {
type = NavType.StringType
},
)
}
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"
}
override val arguments: List<NamedNavArgument>
get() = listOf(
navArgument(EPISODE_URI) {
type = NavType.StringType
},
)
}
public object UpNext : NavigationScreens("upNext") {
public fun destination(): String = navRoute
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt
================================================
/*
* 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.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.painter.Painter
import androidx.compose.ui.layout.ContentScale
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.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.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,
onItemClick: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music),
) {
val mediaTitle = episode.title
val duration = episode.duration
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)
}
FilledTonalButton(
label = {
Text(
mediaTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { onItemClick(episode) },
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(),
)
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun MediaContentPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
modifier: Modifier = Modifier,
) {
AppScaffold(modifier = modifier) {
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)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt
================================================
/*
* 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.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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.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.
* Add to queue and Set Volume.
*/
@Composable
fun SettingsButtons(
volumeUiState: VolumeUiState,
onVolumeClick: () -> Unit,
playerUiState: PlayerUiState,
onPlaybackSpeedChange: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier.fillMaxWidth(0.8124f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
VolumeButtonWithBadge(
onOutputClick = onVolumeClick,
audioOutputUi = AudioOutput.BluetoothHeadset(id = "id", name = "name")
.toAudioOutputUi(),
volumeUiState = volumeUiState,
)
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
PlaybackSpeedButton(
currentPlayerSpeed = playerUiState.episodePlayerState
.playbackSpeed.toMillis().toFloat() / 1000,
onPlaybackSpeedChange = onPlaybackSpeedChange,
enabled = enabled,
)
}
}
}
@Composable
fun PlaybackSpeedButton(
currentPlayerSpeed: Float,
onPlaybackSpeedChange: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
SettingsButton(
modifier = modifier,
onClick = onPlaybackSpeedChange,
enabled = enabled,
imageVector =
when (currentPlayerSpeed) {
1f -> ImageVector.vectorResource(R.drawable.speed_1x)
1.5f -> ImageVector.vectorResource(R.drawable.speed_15x)
else -> {
ImageVector.vectorResource(R.drawable.speed_2x)
}
},
contentDescription = stringResource(R.string.change_playback_speed_content_description),
)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt
================================================
/*
* 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.episode
import androidx.compose.foundation.interaction.MutableInteractionSource
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.automirrored.filled.PlaylistAdd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.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.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
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.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,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
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,
modifier = modifier,
)
}
@Composable
fun EpisodeScreen(
uiState: EpisodeScreenState,
placeholderState: PlaceholderState,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (PlayerEpisode) -> Unit,
onAddToQueue: (PlayerEpisode) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) { contentPadding ->
when (uiState) {
is EpisodeScreenState.Loaded -> {
val title = uiState.episode.episode.title
EpisodeScreenLoaded(
title = title,
episode = uiState.episode.toPlayerEpisode(),
onPlayButtonClick = onPlayButtonClick,
onPlayEpisode = onPlayEpisode,
onAddToQueue = onAddToQueue,
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
}
EpisodeScreenState.Empty -> {
AlertDialog(
visible = true,
onDismissRequest = { onDismiss() },
title = { stringResource(R.string.episode_info_not_available) },
)
}
EpisodeScreenState.Loading -> {
EpisodeScreenLoaded(
title = stringResource(R.string.loading),
episode = PlayerEpisode(),
onPlayButtonClick = { },
onPlayEpisode = { },
onAddToQueue = { },
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
}
}
}
}
@Composable
fun EpisodeScreenLoaded(
title: String,
episode: PlayerEpisode,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (PlayerEpisode) -> Unit,
onAddToQueue: (PlayerEpisode) -> Unit,
columnState: TransformingLazyColumnState,
contentPadding: PaddingValues,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
) {
TransformingLazyColumn(
modifier = modifier,
state = columnState,
contentPadding = contentPadding,
) {
item {
ListHeader {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.placeholder(placeholderState),
)
}
}
item {
LoadedButtonsContent(
episode = episode,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisode = onPlayEpisode,
onAddToQueue = onAddToQueue,
placeholderState = placeholderState,
)
}
if (!placeholderState.isVisible) {
episodeInfoContent(
episode = episode,
)
}
}
}
@Composable
fun LoadedButtonsContent(
episode: PlayerEpisode,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (PlayerEpisode) -> Unit,
onAddToQueue: (PlayerEpisode) -> Unit,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val playInteractionSource = remember { MutableInteractionSource() }
val addToQueueInteractionSource = remember { MutableInteractionSource() }
ButtonGroup(modifier.fillMaxWidth().padding(bottom = 16.dp)) {
FilledIconButton(
onClick = {
onPlayButtonClick()
onPlayEpisode(episode)
},
modifier = Modifier
.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),
)
}
FilledIconButton(
onClick = { onAddToQueue(episode) },
modifier = Modifier
.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),
)
}
}
}
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 {
Text(
text = author,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
}
}
item {
Text(
text = when {
duration != null -> {
// If we have the duration, we combine the date/duration via a
// formatted string
stringResource(
R.string.episode_date_duration,
MediumDateFormatter.format(published),
duration.toMinutes().toInt(),
)
}
// Otherwise we just use the date
else -> MediumDateFormatter.format(published)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(horizontal = 8.dp),
)
}
if (summary != null) {
val summaryInParagraphs = summary.split("\n+".toRegex()).orEmpty()
items(summaryInParagraphs) {
HtmlTextContainer(text = summary) {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current,
modifier = Modifier.listTextPadding(),
)
}
}
}
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun EpisodeScreenEmptyPreview() {
val uiState: EpisodeScreenState = EpisodeScreenState.Empty
EpisodeScreen(
uiState = uiState,
onPlayButtonClick = { },
onPlayEpisode = { _ -> },
onAddToQueue = { _ -> },
onDismiss = {},
placeholderState = rememberPlaceholderState(isVisible = true),
)
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun EpisodeScreenLoadingPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
EpisodeScreenLoaded(
title = episode.title,
episode = episode,
onPlayButtonClick = { },
onPlayEpisode = { },
onAddToQueue = { },
columnState = columnState,
contentPadding = contentPadding,
placeholderState = rememberPlaceholderState(isVisible = true),
)
}
@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,
placeholderState = rememberPlaceholderState(isVisible = false),
)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt
================================================
/*
* 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.episode
/*
* 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.
*/
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.ui.Episode
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* ViewModel that handles the business logic and screen state of the Episode screen.
*/
@HiltViewModel
class EpisodeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
private val episodeUri: String =
savedStateHandle.get<String>(Episode.EPISODE_URI).let {
Uri.decode(it)
}
private val episodeFlow = if (episodeUri != null) {
episodeStore.episodeAndPodcastWithUri(episodeUri)
} else {
flowOf(null)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
null,
)
val uiState: StateFlow<EpisodeScreenState> =
episodeFlow.map {
if (it != null) {
EpisodeScreenState.Loaded(it)
} else {
EpisodeScreenState.Empty
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
EpisodeScreenState.Loading,
)
fun onPlayEpisode(episode: PlayerEpisode) {
episodePlayer.currentEpisode = episode
episodePlayer.play()
}
fun addToQueue(episode: PlayerEpisode) {
episodePlayer.addToQueue(episode)
}
}
@ExperimentalHorologistApi
sealed interface EpisodeScreenState {
data object Loading : EpisodeScreenState
data class Loaded(val episode: EpisodeToPodcast) : EpisodeScreenState
data object Empty : EpisodeScreenState
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt
================================================
/*
* 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.latest_episodes
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.res.painterResource
import androidx.compose.ui.res.stringResource
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.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.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.preview.WearPreviewEpisodes
import com.google.android.horologist.compose.layout.ColumnItemType
import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
@Composable fun LatestEpisodesScreen(
onPlayButtonClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
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,
)
}
@Composable
fun LatestEpisodeScreen(
uiState: LatestEpisodeScreenState,
placeholderState: PlaceholderState,
onPlayButtonClick: () -> Unit,
onDismiss: () -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
onPlayEpisode: (PlayerEpisode) -> Unit,
modifier: Modifier = Modifier,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) { contentPadding ->
when (uiState) {
is LatestEpisodeScreenState.Loaded -> {
LatestEpisodesScreen(
episodeList = uiState.episodeList,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisode = onPlayEpisode,
onPlayEpisodes = onPlayEpisodes,
contentPadding = contentPadding,
scrollState = columnState,
placeholderState = placeholderState,
)
}
is LatestEpisodeScreenState.Empty -> {
AlertDialog(
visible = true,
onDismissRequest = onDismiss,
title = { stringResource(R.string.podcasts_no_episode_podcasts) },
)
}
is LatestEpisodeScreenState.Loading -> {
LatestEpisodesScreen(
episodeList = emptyList(),
onPlayButtonClick = { },
onPlayEpisode = { },
onPlayEpisodes = {},
contentPadding = contentPadding,
scrollState = columnState,
placeholderState = placeholderState,
)
}
}
}
}
@Composable
fun ButtonsContent(
episodes: List<PlayerEpisode>,
onPlayButtonClick: () -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Button(
onClick = {
onPlayButtonClick()
onPlayEpisodes(episodes)
},
enabled = enabled,
icon = {
Icon(
painter = painterResource(id = R.drawable.play),
contentDescription = stringResource(id = R.string.button_play_content_description),
)
},
modifier = modifier.fillMaxWidth().placeholder(placeholderState = placeholderState),
) {
Text(stringResource(id = R.string.button_play_content_description))
}
}
@Composable
fun LatestEpisodesScreen(
episodeList: List<PlayerEpisode>,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (PlayerEpisode) -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
contentPadding: PaddingValues,
scrollState: TransformingLazyColumnState,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
) {
TransformingLazyColumn(
modifier = modifier,
state = scrollState,
contentPadding = contentPadding,
) {
item {
LatestEpisodesListHeader(placeholderState)
}
item {
ButtonsContent(
episodes = episodeList,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisodes = onPlayEpisodes,
placeholderState = placeholderState,
)
}
items(episodeList) { episode ->
MediaContent(
episode = episode,
episodeArtworkPlaceholder = painterResource(id = R.drawable.music),
onItemClick = {
onPlayButtonClick()
onPlayEpisode(episode)
},
)
}
}
}
@Composable
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,
)
}
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun LatestEpisodeScreenLoadedPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
LatestEpisodesScreen(
episodeList = listOf(episode),
onPlayButtonClick = { },
onPlayEpisode = { },
onPlayEpisodes = { },
contentPadding = contentPadding,
scrollState = columnState,
placeholderState = rememberPlaceholderState(isVisible = false),
)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodeViewModel.kt
================================================
/*
* 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.latest_episodes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.domain.GetLatestFollowedEpisodesUseCase
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.example.jetcaster.core.player.model.toPlayerEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class LatestEpisodeViewModel @Inject constructor(
episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase,
private val episodePlayer: EpisodePlayer,
) : ViewModel() {
val uiState: StateFlow<LatestEpisodeScreenState> =
episodesFromFavouritePodcasts.invoke().map { episodeToPodcastList ->
if (episodeToPodcastList.isNotEmpty()) {
LatestEpisodeScreenState.Loaded(
episodeToPodcastList.map {
it.toPlayerEpisode()
},
)
} else {
LatestEpisodeScreenState.Empty
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
LatestEpisodeScreenState.Loading,
)
fun onPlayEpisodes(episodes: List<PlayerEpisode>) {
episodePlayer.currentEpisode = episodes[0]
episodePlayer.play(episodes)
}
fun onPlayEpisode(episode: PlayerEpisode) {
episodePlayer.currentEpisode = episode
episodePlayer.play()
}
}
sealed interface LatestEpisodeScreenState {
data object Loading : LatestEpisodeScreenState
data class Loaded(val episodeList: List<PlayerEpisode>) : LatestEpisodeScreenState
data object Empty : LatestEpisodeScreenState
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt
================================================
/*
* 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.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.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.painter.Painter
import androidx.compose.ui.layout.ContentScale
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.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.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.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
@Composable
fun LibraryScreen(
onLatestEpisodeClick: () -> Unit,
onYourPodcastClick: () -> Unit,
onUpNextClick: () -> Unit,
modifier: Modifier = Modifier,
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,
)
val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) { contentPadding ->
when (val s = uiState) {
is LibraryScreenUiState.Loading ->
LibraryScreen(
columnState = columnState,
contentPadding = contentPadding,
onLatestEpisodeClick = { },
onYourPodcastClick = { },
onUpNextClick = { },
placeholderState = placeholderState,
queue = emptyList(),
)
is LibraryScreenUiState.NoSubscribedPodcast ->
NoSubscribedPodcastScreen(
columnState = columnState,
contentPadding = contentPadding,
topPodcasts = s.topPodcasts,
onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed,
)
is LibraryScreenUiState.Ready ->
LibraryScreen(
columnState = columnState,
contentPadding = contentPadding,
onLatestEpisodeClick = onLatestEpisodeClick,
onYourPodcastClick = onYourPodcastClick,
onUpNextClick = onUpNextClick,
placeholderState = placeholderState,
queue = s.queue,
)
}
}
}
@Composable
fun NoSubscribedPodcastScreen(
columnState: TransformingLazyColumnState,
topPodcasts: List<PodcastInfo>,
onTogglePodcastFollowed: (uri: String) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
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,
podcastArtworkPlaceholder = painterResource(id = R.drawable.music),
onClick = {
onTogglePodcastFollowed(podcast.uri)
},
)
}
} else {
item {
PodcastContent(
podcast = PodcastInfo(),
podcastArtworkPlaceholder = painterResource(id = R.drawable.music),
onClick = {},
)
}
}
}
}
@Composable
private fun PodcastContent(podcast: PodcastInfo, onClick: () -> Unit, podcastArtworkPlaceholder: Painter?, modifier: Modifier = Modifier) {
val mediaTitle = podcast.title
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(),
)
}
@Composable
fun LibraryScreen(
columnState: TransformingLazyColumnState,
placeholderState: PlaceholderState,
contentPadding: PaddingValues,
onLatestEpisodeClick: () -> Unit,
onYourPodcastClick: () -> Unit,
onUpNextClick: () -> Unit,
queue: List<PlayerEpisode>,
modifier: Modifier = Modifier,
) {
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) { contentPadding ->
val transformationSpec = rememberTransformationSpec()
TransformingLazyColumn(state = columnState, contentPadding = contentPadding) {
item {
ListHeader(
modifier = Modifier
.fillMaxWidth()
.transformedHeight(this, transformationSpec)
.placeholder(placeholderState),
transformation = SurfaceTransformation(transformationSpec),
) {
Text(stringResource(R.string.home_library))
}
}
item {
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()
.transformedHeight(this, transformationSpec)
.placeholder(placeholderState = placeholderState),
transformation = SurfaceTransformation(transformationSpec),
)
}
item {
FilledTonalButton(
label = { Text(stringResource(R.string.podcasts)) },
onClick = { onYourPodcastClick() },
icon = {
IconWithBackground(R.drawable.podcast, stringResource(R.string.podcasts))
},
modifier = Modifier
.fillMaxWidth()
.transformedHeight(this, transformationSpec)
.placeholder(placeholderState = placeholderState),
transformation = SurfaceTransformation(transformationSpec),
)
}
item {
ListHeader(
modifier = Modifier
.fillMaxWidth()
.transformedHeight(this, transformationSpec)
.placeholder(placeholderState = placeholderState),
transformation = SurfaceTransformation(transformationSpec),
) {
Text(stringResource(R.string.queue))
}
}
item {
if (queue.isEmpty()) {
QueueEmptyText()
} else {
FilledTonalButton(
label = { Text(stringResource(R.string.up_next)) },
onClick = { onUpNextClick() },
icon = {
IconWithBackground(R.drawable.up_next, stringResource(R.string.up_next))
},
modifier = Modifier
.fillMaxWidth()
.transformedHeight(this, transformationSpec)
.placeholder(placeholderState = placeholderState),
transformation = SurfaceTransformation(transformationSpec),
)
}
}
}
}
}
@Composable
private fun IconWithBackground(resource: Int, contentDescription: String, modifier: Modifier = Modifier) {
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 QueueEmptyText(modifier: Modifier = Modifier) {
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.bodySmall,
)
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun LibraryScreenPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
) {
LibraryScreen(
columnState = rememberTransformingLazyColumnState(),
contentPadding = PaddingValues(),
modifier = Modifier,
onLatestEpisodeClick = {},
onYourPodcastClick = {},
onUpNextClick = {},
queue = listOf(
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 = {},
)
}
}
}
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryViewModel.kt
================================================
/*
* 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
/*
* 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.
*/
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.repository.CategoryStore
import com.example.jetcaster.core.data.repository.EpisodeStore
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.data.repository.PodcastsRepository
import com.example.jetcaster.core.domain.PodcastCategoryFilterUseCase
import com.example.jetcaster.core.model.CategoryTechnology
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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val podcastsRepository: PodcastsRepository,
private val episodeStore: EpisodeStore,
private val podcastStore: PodcastStore,
private val episodePlayer: EpisodePlayer,
private val categoryStore: CategoryStore,
private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase,
) : ViewModel() {
private val defaultCategory = categoryStore.getCategory(CategoryTechnology)
private val topPodcastsFlow = defaultCategory.flatMapLatest {
podcastCategoryFilterUseCase(it?.asExternalModel())
}
private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode()
private val queue = episodePlayer.playerState.map {
it.queue
}
@OptIn(ExperimentalCoroutinesApi::class)
private val latestEpisodeListFlow = podcastStore
.followedPodcastsSortedByLastEpisode()
.flatMapLatest { podcastList ->
if (podcastList.isNotEmpty()) {
combine(podcastList.map { episodeStore.episodesInPodcast(it.podcast.uri, 1) }) {
it.map { episodes ->
episodes.first()
}
}
} else {
flowOf(emptyList())
}
}.map { list ->
(list.map { it.toPlayerEpisode() })
}
val uiState =
combine(
topPodcastsFlow,
followingPodcastListFlow,
latestEpisodeListFlow,
queue,
) { topPodcasts, podcastList, episodeList, queue ->
if (podcastList.isEmpty()) {
LibraryScreenUiState.NoSubscribedPodcast(topPodcasts.topPodcasts)
} else {
LibraryScreenUiState.Ready(podcastList, episodeList, queue)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LibraryScreenUiState.Loading,
)
init {
viewModelScope.launch {
podcastsRepository.updatePodcasts(false)
}
}
fun playEpisode(playerEpisode: PlayerEpisode) {
episodePlayer.play(playerEpisode)
}
fun onTogglePodcastFollowed(podcastUri: String) {
viewModelScope.launch {
podcastStore.togglePodcastFollowed(podcastUri)
}
}
}
sealed interface LibraryScreenUiState {
data object Loading : LibraryScreenUiState
data class NoSubscribedPodcast(val topPodcasts: List<PodcastInfo>) : LibraryScreenUiState
data class Ready(
val subscribedPodcastList: List<PodcastWithExtraInfo>,
val latestEpisodeList: List<PlayerEpisode>,
val queue: List<PlayerEpisode>,
) : LibraryScreenUiState
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt
================================================
/*
* 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.player
/*
* 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.
*/
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.requestFocusOnHierarchyActive
import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.wear.compose.material.ExperimentalWearMaterialApi
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.controls.SeekButtonIncrement
import com.google.android.horologist.media.ui.material3.components.PodcastControlButtons
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
import java.time.Duration
@Composable
fun PlayerScreen(
volumeViewModel: VolumeViewModel,
onVolumeClick: () -> Unit,
modifier: Modifier = Modifier,
playerScreenViewModel: PlayerViewModel = hiltViewModel(),
) {
val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle()
PlayerScreen(
playerScreenViewModel = playerScreenViewModel,
volumeUiState = volumeUiState,
onVolumeClick = onVolumeClick,
onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) },
modifier = modifier,
)
}
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class)
@Composable
private fun PlayerScreen(
playerScreenViewModel: PlayerViewModel,
volumeUiState: VolumeUiState,
onVolumeClick: () -> Unit,
onUpdateVolume: (Int) -> Unit,
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)
PlayerScreenUiState.Empty -> {
PlayerScreen(
mediaDisplay = {
TextMediaDisplay(
title = stringResource(R.string.nothing_playing),
subtitle = "",
titleIcon = DrawableResPaintable(R.drawable.ic_logo),
)
},
controlButtons = {
PodcastControlButtons(
onPlayButtonClick = playerScreenViewModel::onPlay,
onPauseButtonClick = playerScreenViewModel::onPause,
playPauseButtonEnabled = false,
playing = false,
onSeekBackButtonClick = playerScreenViewModel::onRewindBy,
seekBackButtonEnabled = false,
onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy,
seekForwardButtonEnabled = false,
)
},
buttons = {
SettingsButtons(
volumeUiState = volumeUiState,
onVolumeClick = onVolumeClick,
playerUiState = PlayerUiState(),
onPlaybackSpeedChange = playerScreenViewModel::onPlaybackSpeedChange,
enabled = false,
)
},
modifier = modifier,
)
}
is PlayerScreenUiState.Ready -> {
// When screen is ready, episode is always not null, however EpisodePlayerState may
// return a null episode
val episode = state.playerState.episodePlayerState.currentEpisode
val exoPlayer = rememberPlayer(context)
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(modifier = modifier) {
PlayerSurface(
player = exoPlayer,
modifier = Modifier.resizeWithContentScale(
contentScale = ContentScale.Fit,
sourceSizeDp = null,
),
)
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,
),
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
}
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt
================================================
/*
* 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.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.EpisodePlayerState
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.media.ui.state.model.TrackPositionUiModel
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Duration
import javax.inject.Inject
import kotlin.time.toKotlinDuration
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalHorologistApi::class)
data class PlayerUiState(
val episodePlayerState: EpisodePlayerState = EpisodePlayerState(),
var trackPositionUiModel: TrackPositionUiModel = TrackPositionUiModel.Actual.ZERO,
)
/**
* ViewModel that handles the business logic and screen state of the Player screen
*/
@HiltViewModel
@OptIn(ExperimentalHorologistApi::class)
class PlayerViewModel @Inject constructor(private val episodePlayer: EpisodePlayer) : ViewModel() {
val uiState = episodePlayer.playerState.map {
if (it.currentEpisode == null && it.queue.isEmpty()) {
PlayerScreenUiState.Empty
} else {
PlayerScreenUiState.Ready(PlayerUiState(it, buildPositionModel(it)))
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
PlayerScreenUiState.Loading,
)
private fun buildPositionModel(it: EpisodePlayerState) = if (it.currentEpisode != null) {
TrackPositionUiModel.Actual(
percent = it.timeElapsed.toMillis().toFloat() /
(
it.currentEpisode?.duration?.toMillis()
?.toFloat() ?: 0f
),
duration = it.currentEpisode?.duration?.toKotlinDuration()
?: Duration.ZERO.toKotlinDuration(),
position = it.timeElapsed.toKotlinDuration(),
)
} else {
TrackPositionUiModel.Actual.ZERO
}
fun onPlay() {
episodePlayer.play()
}
fun onPause() {
episodePlayer.pause()
}
fun onAdvanceBy() {
episodePlayer.advanceBy(Duration.ofSeconds(10))
}
fun onRewindBy() {
episodePlayer.rewindBy(Duration.ofSeconds(10))
}
fun onPlaybackSpeedChange() {
if (episodePlayer.playerState.value.playbackSpeed == Duration.ofSeconds(2)) {
episodePlayer.decreaseSpeed(speed = Duration.ofMillis(1000))
} else {
episodePlayer.increaseSpeed()
}
}
}
sealed class PlayerScreenUiState {
data object Loading : PlayerScreenUiState()
data class Ready(val playerState: PlayerUiState) : PlayerScreenUiState()
data object Empty : PlayerScreenUiState()
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt
================================================
/*
* 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.podcast
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.res.painterResource
import androidx.compose.ui.res.stringResource
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.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.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.player.model.PlayerEpisode
import com.example.jetcaster.ui.components.MediaContent
import com.example.jetcaster.ui.preview.WearPreviewEpisodes
import com.google.android.horologist.compose.layout.ColumnItemType
import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
@Composable fun PodcastDetailsScreen(
onPlayButtonClick: () -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
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,
onPlayButtonClick = onPlayButtonClick,
modifier = modifier,
)
}
@Composable
fun PodcastDetailsScreen(
uiState: PodcastDetailsScreenState,
placeholderState: PlaceholderState,
onPlayButtonClick: () -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
onPlayEpisode: (List<PlayerEpisode>) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) {
when (uiState) {
is PodcastDetailsScreenState.Loaded -> {
PodcastDetailScreenLoaded(
uiState.episodeList,
uiState.podcast.title,
onPlayButtonClick,
onPlayEpisode,
onEpisodeItemClick,
columnState,
contentPadding,
placeholderState = placeholderState,
)
}
PodcastDetailsScreenState.Empty -> {
AlertDialog(
visible = true,
onDismissRequest = onDismiss,
title = { stringResource(R.string.podcasts_no_episode_podcasts) },
)
}
PodcastDetailsScreenState.Loading -> {
PodcastDetailScreenLoaded(
emptyList(),
"",
{ },
{ },
{ },
columnState,
contentPadding,
placeholderState = placeholderState,
)
}
}
}
}
@Composable
fun PodcastDetailScreenLoaded(
episodeList: List<PlayerEpisode>,
title: String,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (List<PlayerEpisode>) -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
columnState: TransformingLazyColumnState,
contentPadding: PaddingValues,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
) {
TransformingLazyColumn(
modifier = modifier,
state = columnState,
contentPadding = contentPadding,
) {
item {
ListHeader {
Text(
text = title, maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.placeholder(placeholderState),
)
}
}
item {
ButtonsContent(
episodes = episodeList,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisode = onPlayEpisode,
placeholderState = placeholderState,
)
}
items(episodeList) { episode ->
MediaContent(
episode = episode,
episodeArtworkPlaceholder = painterResource(id = R.drawable.music),
onItemClick = onEpisodeItemClick,
)
}
}
}
@Composable
fun ButtonsContent(
episodes: List<PlayerEpisode>,
onPlayButtonClick: () -> Unit,
onPlayEpisode: (List<PlayerEpisode>) -> Unit,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Button(
onClick = {
onPlayButtonClick()
onPlayEpisode(episodes)
},
enabled = enabled,
icon = {
Icon(
painter = painterResource(id = R.drawable.play),
contentDescription = stringResource(id = R.string.button_play_content_description),
)
},
modifier = modifier.fillMaxWidth()
.placeholder(placeholderState = placeholderState),
) {
Text(stringResource(id = R.string.button_play_content_description))
}
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun PodcastDetailsScreenLoadedPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
) {
PodcastDetailsScreen(
uiState = PodcastDetailsScreenState.Loaded(
episodeList = listOf(episode),
podcast = PreviewPodcastEpisodes.first().podcast,
),
onPlayButtonClick = { },
onEpisodeItemClick = {},
onPlayEpisode = {},
onDismiss = {},
placeholderState = rememberPlaceholderState(isVisible = false),
)
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt
================================================
/*
* 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.podcast
/*
* 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.
*/
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* ViewModel that handles the business logic and screen state of the Podcast details screen.
*/
@HiltViewModel
class PodcastDetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
episodeStore: EpisodeStore,
private val episodePlayer: EpisodePlayer,
podcastStore: PodcastStore,
) : ViewModel() {
private val podcastUri: String =
savedStateHandle.get<String>(PodcastDetails.PODCAST_URI).let {
Uri.decode(it)
}
private val podcastFlow = if (podcastUri != null) {
podcastStore.podcastWithExtraInfo(podcastUri)
} else {
flowOf(null)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
null,
)
private val episodeListFlow = podcastFlow.flatMapLatest {
if (it != null) {
episodeStore.episodesInPodcast(it.podcast.uri)
} else {
flowOf(emptyList())
}
}.map { list ->
list.map { it.toPlayerEpisode() }
}
val uiState: StateFlow<PodcastDetailsScreenState> =
combine(
podcastFlow,
episodeListFlow,
) { podcast, episodes ->
if (podcast != null) {
PodcastDetailsScreenState.Loaded(
podcast = podcast.podcast.asExternalModel()
.copy(isSubscribed = podcast.isFollowed),
episodeList = episodes,
)
} else {
PodcastDetailsScreenState.Empty
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
PodcastDetailsScreenState.Loading,
)
fun onPlayEpisodes(episodes: List<PlayerEpisode>) {
episodePlayer.currentEpisode = episodes[0]
episodePlayer.play(episodes)
}
}
@ExperimentalHorologistApi
sealed class PodcastDetailsScreenState {
data object Loading : PodcastDetailsScreenState()
data class Loaded(val episodeList: List<PlayerEpisode>, val podcast: PodcastInfo) : PodcastDetailsScreenState()
data object Empty : PodcastDetailsScreenState()
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt
================================================
/*
* 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.podcasts
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.painter.Painter
import androidx.compose.ui.layout.ContentScale
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.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.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.preview.WearPreviewPodcasts
import com.google.android.horologist.compose.layout.ColumnItemType
import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
@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 -> {
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,
onDismiss = onDismiss,
placeholderState = placeholderState,
modifier = modifier,
)
}
@Composable
fun PodcastsScreen(
podcastsScreenState: PodcastsScreenState,
placeholderState: PlaceholderState,
onPodcastsItemClick: (PodcastInfo) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val columnState = rememberTransformingLazyColumnState()
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) {
when (podcastsScreenState) {
is PodcastsScreenState.Loaded -> PodcastScreenLoaded(
podcastList = podcastsScreenState.podcastList,
onPodcastsItemClick = onPodcastsItemClick,
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
PodcastsScreenState.Empty ->
PodcastScreenEmpty(onDismiss)
PodcastsScreenState.Loading ->
PodcastScreenLoaded(
podcastList = emptyList(),
onPodcastsItemClick = { },
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
}
}
}
@Composable
fun PodcastScreenLoaded(
podcastList: List<PodcastInfo>,
onPodcastsItemClick: (PodcastInfo) -> Unit,
contentPadding: PaddingValues,
columnState: TransformingLazyColumnState,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
) {
TransformingLazyColumn(
modifier = modifier,
state = columnState,
contentPadding = contentPadding,
) {
item {
ListHeader {
Text(
text = stringResource(id = R.string.podcasts),
modifier = Modifier.placeholder(placeholderState),
)
}
}
items(count = podcastList.size) { index ->
MediaContent(
podcast = podcastList[index],
onPodcastsItemClick = onPodcastsItemClick,
)
}
}
}
@Composable
fun PodcastScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
AlertDialog(
visible = true,
title = { Text(stringResource(R.string.podcasts_no_podcasts)) },
onDismissRequest = { onDismiss },
modifier = modifier,
)
}
@Composable
fun MediaContent(
podcast: PodcastInfo,
onPodcastsItemClick: (PodcastInfo) -> Unit,
modifier: Modifier = Modifier,
episodeArtworkPlaceholder: Painter = painterResource(id = R.drawable.music),
) {
val mediaTitle = podcast.title
val secondaryLabel = podcast.author
FilledTonalButton(
label = {
Text(
mediaTitle, maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
)
},
onClick = { onPodcastsItemClick(podcast) },
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(),
)
}
@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 = {})
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsViewModel.kt
================================================
/*
* 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.podcasts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
import com.example.jetcaster.core.data.repository.PodcastStore
import com.example.jetcaster.core.model.PodcastInfo
import com.example.jetcaster.core.model.asExternalModel
import com.google.android.horologist.annotations.ExperimentalHorologistApi
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<PodcastsScreenState> =
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()
}
@ExperimentalHorologistApi
sealed interface PodcastsScreenState {
data object Loading : PodcastsScreenState
data class Loaded(val podcastList: List<PodcastInfo>) : PodcastsScreenState
data object Empty : PodcastsScreenState
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewEpisodes.kt
================================================
/*
* 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.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.example.jetcaster.core.domain.testing.PreviewPlayerEpisodes
import com.example.jetcaster.core.player.model.PlayerEpisode
public class WearPreviewEpisodes : PreviewParameterProvider<PlayerEpisode> {
public override val values: Sequence<PlayerEpisode>
get() = PreviewPlayerEpisodes.asSequence()
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewPodcasts.kt
================================================
/*
* 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.preview
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<PodcastInfo> {
public override val values: Sequence<PodcastInfo>
get() = PreviewPodcasts.asSequence()
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt
================================================
/*
* 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.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
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
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.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.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.preview.WearPreviewEpisodes
import com.google.android.horologist.compose.layout.ColumnItemType
import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
@Composable fun QueueScreen(
onPlayButtonClick: () -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
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,
onDeleteQueueEpisodes = queueViewModel::onDeleteQueueEpisodes,
onDismiss = onDismiss,
)
}
@Composable
fun QueueScreen(
uiState: QueueScreenState,
placeholderState: PlaceholderState,
onPlayButtonClick: () -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
onDeleteQueueEpisodes: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
scrollState = columnState,
contentPadding = contentPadding,
modifier = modifier.placeholderShimmer(placeholderState),
) { contentPadding ->
when (uiState) {
is QueueScreenState.Loaded -> QueueScreenLoaded(
episodeList = uiState.episodeList,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisodes = onPlayEpisodes,
onDeleteQueueEpisodes = onDeleteQueueEpisodes,
onEpisodeItemClick = onEpisodeItemClick,
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
QueueScreenState.Loading -> QueueScreenLoaded(
episodeList = emptyList(),
onPlayButtonClick = { },
onPlayEpisodes = { },
onDeleteQueueEpisodes = { },
onEpisodeItemClick = { },
columnState = columnState,
contentPadding = contentPadding,
placeholderState = placeholderState,
)
QueueScreenState.Empty -> QueueScreenEmpty(onDismiss)
}
}
}
@Composable
fun QueueScreenLoaded(
episodeList: List<PlayerEpisode>,
onPlayButtonClick: () -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
onDeleteQueueEpisodes: () -> Unit,
onEpisodeItemClick: (PlayerEpisode) -> Unit,
columnState: TransformingLazyColumnState,
contentPadding: PaddingValues,
placeholderState: PlaceholderState,
modifier: Modifier = Modifier,
) {
TransformingLazyColumn(
modifier = modifier,
state = columnState,
contentPadding = contentPadding,
) {
item {
ListHeader {
Text(
text = stringResource(R.string.queue),
modifier = Modifier.placeholder(placeholderState),
)
}
}
item {
ButtonsContent(
episodes = episodeList,
onPlayButtonClick = onPlayButtonClick,
onPlayEpisodes = onPlayEpisodes,
onDeleteQueueEpisodes = onDeleteQueueEpisodes,
placeholderState = placeholderState,
)
}
items(episodeList) { episode ->
MediaContent(
episode = episode,
episodeArtworkPlaceholder = painterResource(id = R.drawable.music),
onItemClick = onEpisodeItemClick,
)
}
}
}
@Composable
fun QueueScreenEmpty(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
AlertDialog(
visible = true,
onDismissRequest = { onDismiss() },
title = { Text(stringResource(R.string.display_nothing_in_queue)) },
text = { Text(stringResource(R.string.no_episodes_from_queue)) },
modifier = modifier,
)
}
@Composable
fun ButtonsContent(
episodes: List<PlayerEpisode>,
onPlayButtonClick: () -> Unit,
onPlayEpisodes: (List<PlayerEpisode>) -> Unit,
onDeleteQueueEpisodes: () -> Unit,
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,
) {
ButtonGroup(Modifier.fillMaxWidth()) {
FilledIconButton(
onClick = {
onPlayButtonClick()
onPlayEpisodes(episodes)
},
modifier = Modifier
.weight(weight = 0.7F)
.animateWidth(interactionSource1)
.placeholder(placeholderState = placeholderState),
enabled = enabled,
interactionSource = interactionSource1,
shapes = IconButtonShapes(MaterialTheme.shapes.medium),
) {
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)
.animateWidth(interactionSource2)
.placeholder(placeholderState = placeholderState),
interactionSource = interactionSource2,
enabled = enabled,
) {
Icon(
painter = painterResource(id = R.drawable.delete),
contentDescription =
stringResource(id = R.string.button_delete_queue_content_description),
)
}
}
}
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun QueueScreenLoadedPreview(
@PreviewParameter(WearPreviewEpisodes::class)
episode: PlayerEpisode,
) {
val columnState = rememberTransformingLazyColumnState()
val contentPadding = rememberResponsiveColumnPadding(
first = ColumnItemType.ListHeader,
last = ColumnItemType.Button,
)
QueueScreenLoaded(
episodeList = listOf(episode),
onPlayButtonClick = { },
onPlayEpisodes = { },
onDeleteQueueEpisodes = { },
onEpisodeItemClick = { },
columnState = columnState,
contentPadding = contentPadding,
placeholderState = rememberPlaceholderState(isVisible = false),
)
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun QueueScreenEmptyPreview() {
QueueScreenEmpty(onDismiss = {})
}
================================================
FILE: Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueViewModel.kt
================================================
/*
* 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.queue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetcaster.core.player.EpisodePlayer
import com.example.jetcaster.core.player.model.PlayerEpisode
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* ViewModel that handles the business logic and screen state of the Queue screen.
*/
@HiltViewModel
class QueueViewModel @Inject constructor(private val episodePlayer: EpisodePlayer) : ViewModel() {
val uiState: StateFlow<QueueScreenState> = episodePlayer.playerState.map {
if (it.queue.isNotEmpty()) {
QueueScreenState.Loaded(it.queue)
} else {
QueueScreenState.Empty
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
QueueScreenState.Loading,
)
fun onPlayEpisode(episode: PlayerEpisode) {
episodePlayer.currentEpisode = episode
episodePlayer.play()
}
fun onPlayEpisodes(episodes: List<PlayerEpisode>) {
episodePlayer.currentEpisode = episodes[0]
episodePlayer.play(episodes)
}
fun onDeleteQueueEpisodes() {
episodePlayer.removeAllFromQueue()
}
}
@ExperimentalHorologistApi
sealed interface QueueScreenState {
data object Loading : QueueScreenState
data class Loaded(val episodeList: List<PlayerEpisode>) : QueueScreenState
data object Empty : QueueScreenState
}
================================================
FILE: Jetcaster/wear/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values/dimens.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 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
http://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.
-->
<resources>
<!-- Round app icon can take all of default space -->
<dimen name="splash_screen_icon_size">48dp</dimen>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 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
http://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.
-->
<resources>
<string name="app_name">Jetcaster</string>
<string name="connection_error_title">Connection error</string>
<string name="connection_error_message">Unable to fetch podcasts feeds.\nCheck your internet connection and try again.</string>
<string name="retry_label">Retry</string>
<string name="podcasts">Podcasts</string>
<string name="latest_episodes">Latest episodes</string>
<string name="home_library">Your library</string>
<string name="queue">Queue</string>
<string name="up_next">Up Next</string>
<string name="home_discover">Discover</string>
<string name="settings">Settings</string>
<string name="entity_no_featured_podcasts">Your library is empty. Checkout the latest podcasts.</string>
<string name="entity_no_featured_podcasts_dialog_cancel_button_content_description">Cancel</string>
<string name="entity_no_featured_podcasts_dialog_refresh_button_content_description">Refresh</string>
<string name="speed_button_content_description">Change Speed</string>
<string name="download_button_content_description">Download</string>
<string name="button_play_content_description">Play episodes</string>
<string name="button_delete_queue_content_description">Delete queue</string>
<string name="updated_longer">Updated a while ago</string>
<plurals name="updated_weeks_ago">
<item quantity="one">Updated %d week ago</item>
<item quantity="other">Updated %d weeks ago</item>
</plurals>
<plurals name="updated_days_ago">
<item quantity="one">Updated yesterday</item>
<item quantity="other">Updated %d days ago</item>
</plurals>
<string name="updated_today">Updated today</string>
<string name="episode_date_duration">%1$s &#8226; %2$d mins</string>
<string name="cd_search">Search</string>
<string name="cd_account">Account</string>
<string name="cd_add">Add</string>
<string name="cd_back">Back</string>
<string name="cd_more">More</string>
<string name="cd_play">Play</string>
<string name="cd_skip_previous">Skip previous</string>
<string name="cd_reply10">Reply 10 seconds</string>
<string name="cd_forward30">Forward 30 seconds</string>
<string name="cd_skip_next">Skip next</string>
<string name="cd_unfollow">Unfollow</string>
<string name="cd_follow">Follow</string>
<string name="cd_following">Following</string>
<string name="cd_not_following">Not following</string>
<string name="nothing_playing">Nothing playing</string>
<string name="speed">Speed</string>
<string name="increase_playback_speed">Increase playback speed</string>
<string name="decrease_playback_speed">Decrease playback speed</string>
<string name="change_playback_speed_content_description">Change playback speed</string>
<string name="podcasts_no_podcasts">No podcasts available at the moment</string>
<string name="loading">Loading</string>
<string name="podcasts_no_episode_podcasts">No episodes available at the moment</string>
<string name="no_title">No title</string>
<string name="podcasts_failed_dialog_cancel_button_content_description">Cancel</string>
<string name="display_nothing_in_queue">No episode in the queue</string>
<string name="add_episode_to_queue">Add an episode to the queue</string>
<string name="no_episodes_from_queue">There are no episodes from the queue</string>
<string name="add_to_queue_content_description">Add to queue</string>
<string name="episode_info_not_available">Episode info not available at the moment</string>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values/themes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.App" parent="@android:style/Theme.DeviceDefault" />
<style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground">
<!-- Set the splash screen background to black -->
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen</item>
<item name="postSplashScreenTheme">@style/Theme.App</item>
</style>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values-round/dimens.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 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
http://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.
-->
<resources>
<!-- Round app icon can take all of default space -->
<dimen name="splash_screen_icon_size">48dp</dimen>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values-round/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 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
http://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.
-->
<resources>
</resources>
================================================
FILE: Jetcaster/wear/src/main/res/values-round/themes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.App" parent="@android:style/Theme.DeviceDefault" />
<style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground">
<!-- Set the splash screen background to black -->
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen</item>
<item name="postSplashScreenTheme">@style/Theme.App</item>
</style>
</resources>
================================================
FILE: Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt
================================================
/*
* 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.
* 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
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavigationTest {
@get:Rule
val rule = createAndroidComposeRule(MainActivity::class.java)
@Test
fun launchAndNavigate() {
val activity = rule.activity
val navController = activity.navController
rule.waitUntil {
navController.currentDestination?.route != null
}
assertEquals("player?page={page}", navController.currentDestination?.route)
navController.navigateToUpNext()
assertEquals("upNext", navController.currentDestination?.route)
}
}
================================================
FILE: Jetchat/README.md
================================================
<img src="screenshots/jetchatlogo.png"/>
# Jetchat sample
Jetchat is a sample chat app built with [Jetpack Compose][compose].
To try out this sample app, use the latest stable version
of [Android Studio](https://developer.android.com/studio).
You can clone this repository or import the
project from Android Studio following the steps
[here](https://developer.android.com/jetpack/compose/setup#sample).
This sample showcases:
* UI state management
* Integration with Architecture Components: Navigation, Fragments, ViewModel
* Back button handling
* Text Input and focus management
* Multiple types of animations and transitions
* Saved state across configuration changes
* Material Design 3 theming and Material You dynamic color
* UI tests
## Screenshots
<img src="screenshots/screenshots.png"/>
<img src="screenshots/widget.png" width="300"/>
<img src="screenshots/widget_discoverability.png" width="300"/>
### Status: 🚧 In progress
Jetchat is still in under development, and some features are not yet implemented.
## Features
### UI State management
The [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) composable is the entry point to this screen and takes a [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt) that defines the data to be displayed. This doesn't mean all the state is served from a single point: composables can have their own state too. For an example, see `scrollState` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) or `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt)
### Architecture Components
The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt) shows how to pass data between fragments with the [Navigation component](https://developer.android.com/guide/navigation) and observe state from a
[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata).
### Back button handling
When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. The implementation can be found in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
### Text Input and focus management
When the Emoji panel is shown the keyboard must be hidden and vice versa. This is achieved with a combination of the [FocusRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/FocusRequester) and [onFocusChanged](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/package-summary#(androidx.compose.ui.Modifier).onFocusChanged(kotlin.Function1)) APIs.
### Multiple types of animations and transitions
This sample uses animations ranging from simple `AnimatedVisibility` in [FunctionalityNotAvailablePanel](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) to choreographed transitions found in the [FloatingActionButton](https://material.io/develop/android/components/floating-action-button) of the Profile screen and implemented in [AnimatingFabContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt)
### Edge-to-edge UI with synchronized IME transitions
This sample is laid out [edge-to-edge](https://medium.com/androiddevelopers/gesture-navigation-going-edge-to-edge-812f62e4e83e), drawing its content behind the system bars for a more immersive look.
The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsPadding().imePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
### Saved state across configuration changes
Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
### Material Design 3 theming and Material You dynamic color
Jetchat follows the [Material Design 3](https://m3.material.io) principles and uses the `MaterialTheme` composable and M3 components. On Android 12+ Jetchat supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. Jetchat uses a custom, branded color scheme as a fallback. It also implements custom typography using the Karla and Montserrat font families.
### Nested scrolling interop
Jetchat contains an example of how to use [`rememberNestedScrollInteropConnection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#rememberNestedScrollInteropConnection()) to achieve successful nested scroll interop between a View parent that implements `androidx.core.view.NestedScrollingParent3` and a Compose child. The example used here is a combination of a View parent `CoordinatorLayout` and a nested, Compose child `BoxWithConstraints` in [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt).
### UI tests
In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll find a suite of UI tests that showcase interesting patterns in Compose:
#### [ConversationTest](app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt)
UI tests for the Conversation screen. Includes a test that checks the behavior of the app when dark mode changes.
#### [NavigationTest](app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt)
Shows how to write tests that assert directly on the [Navigation Controller](https://developer.android.com/reference/androidx/navigation/NavController).
#### [UserInputTest](app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt)
Checks that the user input composable, including extended controls, behave as expected showing and hiding the keyboard.
## Known issues
1. If the emoji selector is shown, clicking on the TextField can sometimes show both input methods.
Tracked in https://issuetracker.google.com/164859446
2. There are only two profiles, clicking on anybody except "me" will show the same data.
## License
```
Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
[compose]: https://developer.android.com/jetpack/compose
================================================
FILE: Jetchat/build.gradle.kts
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://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.
*/
plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.version.catalog.update)
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.spotless) apply false
}
apply("${project.rootDir}/buildscripts/toml-updater-config.gradle")
subprojects {
apply(plugin = "com.diffplug.spotless")
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
kotlin {
target("**/*.kt")
targetExclude("${layout.buildDirectory}/**/*.kt")
ktlint()
licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
}
kotlinGradle {
target("*.gradle.kts")
targetExclude("${layout.buildDirectory}/**/*.kt")
ktlint()
// Look for the first line that doesn't have a block comment (assumed to be the license)
licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)")
}
}
}
================================================
FILE: Jetchat/settings.gradle.kts
================================================
/*
* 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.
*/
val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID")
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
snapshotVersion?.let {
println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/")
maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") }
}
google()
mavenCentral()
}
}
rootProject.name = "Jetchat"
include(":app")
================================================
FILE: Jetchat/app/build.gradle.kts
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose)
}
android {
compileSdk = libs.versions.compileSdk.get().toInt()
namespace = "com.example.compose.jetchat"
defaultConfig {
applicationId = "com.example.compose.jetchat"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
signingConfigs {
// Important: change the keystore for a production deployment
val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore")
val localKeystore = rootProject.file("debug_2.keystore")
val hasKeyInfo = userKeystore.exists()
create("release") {
storeFile = if (hasKeyInfo) userKeystore else localKeystore
storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password")
keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias")
keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password")
}
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
kotlinOptions {
jvmTarget = "17"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
viewBinding = true
}
packaging.resources {
// Multiple dependency bring these files in. Exclude them to enable
// our test APK to build (has no effect on our AARs)
excludes += "/META-INF/AL2.0"
excludes += "/META-INF/LGPL2.1"
}
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.util)
implementation(libs.androidx.compose.ui.viewbinding)
implementation(libs.androidx.compose.ui.googlefonts)
debugImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
================================================
FILE: Jetchat/app/src/androidTest/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://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.
-->
<!-- Added as workaround for https://github.com/android/android-test/issues/1412 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity"
android:exported="true"
tools:node="merge">
<intent-filter tools:node="removeAll" />
</activity>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity"
android:exported="true"
tools:node="merge">
<intent-filter tools:node="removeAll" />
</activity>
</application>
</manifest>
================================================
FILE: Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.activity.ComponentActivity
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.compose.jetchat.conversation.ConversationContent
import com.example.compose.jetchat.conversation.ConversationTestTag
import com.example.compose.jetchat.conversation.ConversationUiState
import com.example.compose.jetchat.data.exampleUiState
import com.example.compose.jetchat.theme.JetchatTheme
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* Checks that the features in the Conversation screen work as expected.
*/
class ConversationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val themeIsDark = MutableStateFlow(false)
@Before
fun setUp() {
// Launch the conversation screen
composeTestRule.setContent {
JetchatTheme(isDarkTheme = themeIsDark.collectAsStateWithLifecycle(false).value) {
ConversationContent(
uiState = conversationTestUiState,
navigateToProfile = { },
onNavIconPressed = { },
)
}
}
}
@Test
fun app_launches() {
// Check that the conversation screen is visible on launch
composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed()
}
@Test
fun userScrollsUp_jumpToBottomAppears() {
// Check list is snapped to bottom and swipe up
findJumpToBottom().assertDoesNotExist()
composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput {
this.swipe(
start = this.center,
end = Offset(this.center.x, this.center.y + 500),
durationMillis = 200,
)
}
// Check that the jump to bottom button is shown
findJumpToBottom().assertIsDisplayed()
}
@Test
fun jumpToBottom_snapsToBottomAndDisappears() {
// When the scroll is not snapped to the bottom
composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput {
this.swipe(
start = this.center,
end = Offset(this.center.x, this.center.y + 500),
durationMillis = 200,
)
}
// Snap scroll to the bottom
findJumpToBottom().performClick()
// Check that the button is hidden
findJumpToBottom().assertDoesNotExist()
}
@Test
fun jumpToBottom_snapsToBottomAfterUserInteracted() {
// First swipe
composeTestRule.onNodeWithTag(
testTag = ConversationTestTag,
useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850
).performTouchInput {
this.swipe(
start = this.center,
end = Offset(this.center.x, this.center.y + 500),
durationMillis = 200,
)
}
// Second, snap to bottom
findJumpToBottom().performClick()
// Open Emoji selector
openEmojiSelector()
// Assert that the list is still snapped to bottom
findJumpToBottom().assertDoesNotExist()
}
@Test
fun changeTheme_scrollIsPersisted() {
// Swipe to show the jump to bottom button
composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput {
this.swipe(
start = this.center,
end = Offset(this.center.x, this.center.y + 500),
durationMillis = 200,
)
}
// Check that the jump to bottom button is shown
findJumpToBottom().assertIsDisplayed()
// Set theme to dark
themeIsDark.value = true
// Check that the jump to bottom button is still shown
findJumpToBottom().assertIsDisplayed()
}
private fun findJumpToBottom() = composeTestRule.onNodeWithText(
composeTestRule.activity.getString(R.string.jumpBottom),
useUnmergedTree = true,
)
private fun openEmojiSelector() = composeTestRule
.onNodeWithContentDescription(
label = composeTestRule.activity.getString(R.string.emoji_selector_bt_desc),
useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850
)
.performClick()
}
/**
* Make the list of messages longer so the test makes sense on tablets.
*/
private val conversationTestUiState = ConversationUiState(
initialMessages = (exampleUiState.messages.plus(exampleUiState.messages)),
channelName = "#composers",
channelMembers = 42,
)
================================================
FILE: Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.test.espresso.Espresso
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
/**
* Checks that the navigation flows in the app are correct.
*/
class NavigationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<NavActivity>()
@Test
fun app_launches() {
// Check app launches at the correct destination
assertEquals(getNavController().currentDestination?.id, R.id.nav_home)
}
@Test
fun profileScreen_back_conversationScreen() {
val navController = getNavController()
// Navigate to profile \
navigateToProfile("Taylor Brooks")
// Check profile is displayed
assertEquals(navController.currentDestination?.id, R.id.nav_profile)
// Extra UI check
composeTestRule
.onNodeWithText(composeTestRule.activity.getString(R.string.display_name))
.assertIsDisplayed()
// Press back
Espresso.pressBack()
// Check that we're home
assertEquals(navController.currentDestination?.id, R.id.nav_home)
}
/**
* Regression test for https://github.com/android/compose-samples/issues/670
*/
@Test
fun drawer_conversationScreen_backstackPopUp() {
navigateToProfile("Ali Conors (you)")
navigateToHome()
navigateToProfile("Taylor Brooks")
navigateToHome()
// Chewie, we're home
assertEquals(getNavController().currentDestination?.id, R.id.nav_home)
}
private fun navigateToProfile(name: String) {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.getString(R.string.navigation_drawer_open),
).performClick()
composeTestRule.onNode(hasText(name) and isInDrawer()).performClick()
}
private fun isInDrawer() = hasAnyAncestor(isDrawer())
private fun isDrawer() = SemanticsMatcher.expectValue(
SemanticsProperties.PaneTitle,
composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu),
)
private fun navigateToHome() {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.getString(R.string.navigation_drawer_open),
).performClick()
composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick()
}
private fun getNavController(): NavController {
return composeTestRule.activity.findNavController(R.id.nav_host_fragment)
}
}
================================================
FILE: Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.espresso.Espresso
import com.example.compose.jetchat.conversation.ConversationContent
import com.example.compose.jetchat.conversation.KeyboardShownKey
import com.example.compose.jetchat.data.exampleUiState
import com.example.compose.jetchat.theme.JetchatTheme
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
/**
* Checks that the user input composable, including extended controls, behave as expected.
*/
class UserInputTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val activity by lazy { composeTestRule.activity }
@Before
fun setUp() {
// Launch the conversation screen
composeTestRule.setContent {
JetchatTheme {
ConversationContent(
uiState = exampleUiState,
navigateToProfile = { },
onNavIconPressed = { },
)
}
}
}
@Test
@Ignore("Issue with keyboard sync https://issuetracker.google.com/169235317")
fun emojiSelector_isClosedWithBack() {
// Open emoji selector
openEmojiSelector()
// Check emoji selector is displayed
assertEmojiSelectorIsDisplayed()
composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, false))
.assertExists()
// Press back button
Espresso.pressBack()
// TODO: Workaround for synchronization issue with "back"
// https://issuetracker.google.com/169235317
composeTestRule.waitUntil(timeoutMillis = 10_000) {
composeTestRule
.onAllNodesWithContentDescription(activity.getString(R.string.emoji_selector_desc))
.fetchSemanticsNodes().isEmpty()
}
// Check the emoji selector is not displayed
assertEmojiSelectorDoesNotExist()
}
@Test
fun extendedUserInputShown_textFieldClicked_extendedUserInputHides() {
openEmojiSelector()
// Click on text field
clickOnTextField()
// Check the emoji selector is not displayed
assertEmojiSelectorDoesNotExist()
}
@Test
fun keyboardShown_emojiSelectorOpened_keyboardHides() {
// Click on text field to open the soft keyboard
clickOnTextField()
// TODO: Soft keyboard is not correctly synchronized
// https://issuetracker.google.com/169235317
Thread.sleep(200)
composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, true)).assertExists()
// When the emoji selector is extended
openEmojiSelector()
// Check that the keyboard is hidden
composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, false)).assertExists()
}
@Test
@Ignore("Flaky due to https://issuetracker.google.com/169235317")
fun sendButton_enableToggles() {
// Given an initial state where there's no text in the textfield,
// check that the send button is disabled.
findSendButton().assertIsNotEnabled()
// Add some text to the input field
findTextInputField().performTextInput("Some text")
// The send button should be enabled
findSendButton().assertIsEnabled()
}
private fun clickOnTextField() = composeTestRule
.onNodeWithContentDescription(activity.getString(R.string.textfield_desc))
.performClick()
private fun openEmojiSelector() = composeTestRule
.onNodeWithContentDescription(
label = activity.getString(R.string.emoji_selector_bt_desc),
useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850
)
.performClick()
private fun assertEmojiSelectorIsDisplayed() = composeTestRule
.onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc))
.assertIsDisplayed()
private fun assertEmojiSelectorDoesNotExist() = composeTestRule
.onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc))
.assertDoesNotExist()
private fun findSendButton() = composeTestRule.onNodeWithText(activity.getString(R.string.send))
private fun findTextInputField(): SemanticsNodeInteraction {
return composeTestRule.onNode(
hasSetTextAction() and
hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))),
)
}
}
================================================
FILE: Jetchat/app/src/androidTest/java/com/example/compose/jetchat/Utils.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
/**
* Used to debug the semantic tree.
*/
fun ComposeTestRule.dumpSemanticNodes() {
this.onRoot().printToLog(tag = "JetchatLog")
}
================================================
FILE: Jetchat/app/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Jetchat.NoActionBar">
<activity
android:name=".NavActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".widget.WidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_unread_messages_info" />
</receiver>
</application>
</manifest>
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Used to communicate between screens.
*/
class MainViewModel : ViewModel() {
private val _drawerShouldBeOpened = MutableStateFlow(false)
val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow()
fun openDrawer() {
_drawerShouldBeOpened.value = true
}
fun resetOpenDrawerAction() {
_drawerShouldBeOpened.value = false
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.material3.DrawerValue.Closed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import com.example.compose.jetchat.components.JetchatDrawer
import com.example.compose.jetchat.databinding.ContentMainBinding
import kotlinx.coroutines.launch
/**
* Main activity for the app.
*/
class NavActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets }
setContentView(
ComposeView(this).apply {
consumeWindowInsets = false
setContent {
val drawerState = rememberDrawerState(initialValue = Closed)
val drawerOpen by viewModel.drawerShouldBeOpened
.collectAsStateWithLifecycle()
var selectedMenu by remember { mutableStateOf("composers") }
if (drawerOpen) {
// Open drawer and reset state in VM.
LaunchedEffect(Unit) {
// wrap in try-finally to handle interruption whiles opening drawer
try {
drawerState.open()
} finally {
viewModel.resetOpenDrawerAction()
}
}
}
val scope = rememberCoroutineScope()
JetchatDrawer(
drawerState = drawerState,
selectedMenu = selectedMenu,
onChatClicked = {
findNavController().popBackStack(R.id.nav_home, false)
scope.launch {
drawerState.close()
}
selectedMenu = it
},
onProfileClicked = {
val bundle = bundleOf("userId" to it)
findNavController().navigate(R.id.nav_profile, bundle)
scope.launch {
drawerState.close()
}
selectedMenu = it
},
) {
AndroidViewBinding(ContentMainBinding::inflate)
}
}
},
)
}
override fun onSupportNavigateUp(): Boolean {
return findNavController().navigateUp() || super.onSupportNavigateUp()
}
/**
* See https://issuetracker.google.com/142847973
*/
private fun findNavController(): NavController {
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
return navHostFragment.navController
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@Composable
fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
text = {
Text(
text = "Functionality not available \uD83D\uDE48",
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = "CLOSE")
}
},
)
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt
/**
* A layout that shows an icon and a text element used as the content for a FAB that extends with
* an animation.
*/
@Composable
fun AnimatingFabContent(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
modifier: Modifier = Modifier,
extended: Boolean = true,
) {
val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed
val transition = updateTransition(currentState, "fab_transition")
val textOpacity by transition.animateFloat(
transitionSpec = {
if (targetState == ExpandableFabStates.Collapsed) {
tween(
easing = LinearEasing,
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
)
} else {
tween(
easing = LinearEasing,
delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
)
}
},
label = "fab_text_opacity",
) { state ->
if (state == ExpandableFabStates.Collapsed) {
0f
} else {
1f
}
}
val fabWidthFactor by transition.animateFloat(
transitionSpec = {
if (targetState == ExpandableFabStates.Collapsed) {
tween(
easing = FastOutSlowInEasing,
durationMillis = transitionDuration,
)
} else {
tween(
easing = FastOutSlowInEasing,
durationMillis = transitionDuration,
)
}
},
label = "fab_width_factor",
) { state ->
if (state == ExpandableFabStates.Collapsed) {
0f
} else {
1f
}
}
// Deferring reads using lambdas instead of Floats here can improve performance,
// preventing recompositions.
IconAndTextRow(
icon,
text,
{ textOpacity },
{ fabWidthFactor },
modifier = modifier,
)
}
@Composable
private fun IconAndTextRow(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read
widthProgress: () -> Float,
modifier: Modifier,
) {
Layout(
modifier = modifier,
content = {
icon()
Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) {
text()
}
},
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val textPlaceable = measurables[1].measure(constraints)
val height = constraints.maxHeight
// FAB has an aspect ratio of 1 so the initial width is the height
val initialWidth = height.toFloat()
// Use it to get the padding
val iconPadding = (initialWidth - iconPlaceable.width) / 2f
// The full width will be : padding + icon + padding + text + padding
val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3
// Apply the animation factor to go from initialWidth to fullWidth
val width = lerp(initialWidth, expandedWidth, widthProgress())
layout(width.roundToInt(), height) {
iconPlaceable.place(
iconPadding.roundToInt(),
constraints.maxHeight / 2 - iconPlaceable.height / 2,
)
textPlaceable.place(
(iconPlaceable.width + iconPadding * 2).roundToInt(),
constraints.maxHeight / 2 - textPlaceable.height / 2,
)
}
}
}
private enum class ExpandableFabStates { Collapsed, Extended }
private const val transitionDuration = 200
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.components
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
/**
* Applied to a Text, it sets the distance between the top and the first baseline. It
* also makes the bottom of the element coincide with the last baseline of the text.
*
* _______________
* | | ↑
* | | | heightFromBaseline
* |Hello, World!| ↓
* ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
*
* This modifier can be used to distribute multiple text elements using a certain distance between
* baselines.
*/
data class BaselineHeightModifier(val heightFromBaseline: Dp) : LayoutModifier {
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val textPlaceable = measurable.measure(constraints)
val firstBaseline = textPlaceable[FirstBaseline]
val lastBaseline = textPlaceable[LastBaseline]
val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
return layout(constraints.maxWidth, height) {
val topY = heightFromBaseline.roundToPx() - firstBaseline
textPlaceable.place(0, topY)
}
}
}
fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline))
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.example.compose.jetchat.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.compose.jetchat.R
import com.example.compose.jetchat.theme.JetchatTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JetchatAppBar(
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
onNavIconPressed: () -> Unit = { },
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
actions = actions,
title = title,
scrollBehavior = scrollBehavior,
navigationIcon = {
JetchatIcon(
contentDescription = stringResource(id = R.string.navigation_drawer_open),
modifier = Modifier
.size(64.dp)
.clickable(onClick = onNavIconPressed)
.padding(16.dp),
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun JetchatAppBarPreview() {
JetchatTheme {
JetchatAppBar(title = { Text("Preview!") })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun JetchatAppBarPreviewDark() {
JetchatTheme(isDarkTheme = true) {
JetchatAppBar(title = { Text("Preview!") })
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.components
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.compose.jetchat.R
import com.example.compose.jetchat.data.colleagueProfile
import com.example.compose.jetchat.data.meProfile
import com.example.compose.jetchat.theme.JetchatTheme
import com.example.compose.jetchat.widget.WidgetReceiver
@Composable
fun JetchatDrawerContent(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit, selectedMenu: String = "composers") {
// Use windowInsetsTopHeight() to add a spacer which pushes the drawer content
// below the status bar (y-axis)
Column {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
DrawerHeader()
DividerItem()
DrawerItemHeader("Chats")
ChatItem("composers", selectedMenu == "composers") {
onChatClicked("composers")
}
ChatItem("droidcon-nyc", selectedMenu == "droidcon-nyc") {
onChatClicked("droidcon-nyc")
}
DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
DrawerItemHeader("Recent Profiles")
ProfileItem(
"Ali Conors (you)", meProfile.photo,
selectedMenu == meProfile.userId,
) {
onProfileClicked(meProfile.userId)
}
ProfileItem(
"Taylor Brooks", colleagueProfile.photo,
selectedMenu == colleagueProfile.userId,
) {
onProfileClicked(colleagueProfile.userId)
}
if (widgetAddingIsSupported(LocalContext.current)) {
DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
DrawerItemHeader("Settings")
WidgetDiscoverability()
}
}
}
@Composable
private fun DrawerHeader() {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) {
JetchatIcon(
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Image(
painter = painterResource(id = R.drawable.jetchat_logo),
contentDescription = null,
modifier = Modifier.padding(start = 8.dp),
)
}
}
@Composable
private fun DrawerItemHeader(text: String) {
Box(
modifier = Modifier
.heightIn(min = 52.dp)
.padding(horizontal = 28.dp),
contentAlignment = CenterStart,
) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) {
val background = if (selected) {
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
} else {
Modifier
}
Row(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(CircleShape)
.then(background)
.clickable(onClick = onChatClicked),
verticalAlignment = CenterVertically,
) {
val iconTint = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Icon(
painter = painterResource(id = R.drawable.ic_jetchat),
tint = iconTint,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
contentDescription = null,
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.padding(start = 12.dp),
)
}
}
@Composable
private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) {
val background = if (selected) {
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
} else {
Modifier
}
Row(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(CircleShape)
.then(background)
.clickable(onClick = onProfileClicked),
verticalAlignment = CenterVertically,
) {
val paddingSizeModifier = Modifier
.padding(start = 16.dp, top = 16.dp, bottom = 16.dp)
.size(24.dp)
if (profilePic != null) {
Image(
painter = painterResource(id = profilePic),
modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)),
contentScale = ContentScale.Crop,
contentDescription = null,
)
} else {
Spacer(modifier = paddingSizeModifier)
}
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 12.dp),
)
}
}
@Composable
fun DividerItem(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier = modifier,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
)
}
@Composable
@Preview
fun DrawerPreview() {
JetchatTheme {
Surface {
Column {
JetchatDrawerContent({}, {})
}
}
}
}
@Composable
@Preview
fun DrawerPreviewDark() {
JetchatTheme(isDarkTheme = true) {
Surface {
Column {
JetchatDrawerContent({}, {})
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun WidgetDiscoverability() {
val context = LocalContext.current
Row(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(CircleShape)
.clickable(onClick = {
addWidgetToHomeScreen(context)
}),
verticalAlignment = CenterVertically,
) {
Text(
stringResource(id = R.string.add_widget_to_home_page),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 12.dp),
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun addWidgetToHomeScreen(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val myProvider = ComponentName(context, WidgetReceiver::class.java)
if (widgetAddingIsSupported(context)) {
appWidgetManager.requestPinAppWidget(myProvider, null, null)
}
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun widgetAddingIsSupported(context: Context): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt
================================================
/*
* 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.compose.jetchat.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import com.example.compose.jetchat.R
@Composable
fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) {
val semantics = if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
Box(modifier = modifier.then(semantics)) {
Icon(
painter = painterResource(id = R.drawable.ic_jetchat_back),
contentDescription = null,
tint = MaterialTheme.colorScheme.primaryContainer,
)
Icon(
painter = painterResource(id = R.drawable.ic_jetchat_front),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.components
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue.Closed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import com.example.compose.jetchat.theme.JetchatTheme
@Composable
fun JetchatDrawer(
drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
selectedMenu: String,
onProfileClicked: (String) -> Unit,
onChatClicked: (String) -> Unit,
content: @Composable () -> Unit,
) {
JetchatTheme {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
drawerState = drawerState,
drawerContainerColor = MaterialTheme.colorScheme.background,
drawerContentColor = MaterialTheme.colorScheme.onBackground,
) {
JetchatDrawerContent(
onProfileClicked = onProfileClicked,
onChatClicked = onChatClicked,
selectedMenu = selectedMenu,
)
}
},
content = content,
)
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.example.compose.jetchat.conversation
import android.content.ClipDescription
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFrom
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.mimeTypes
import androidx.compose.ui.draganddrop.toAndroidDragEvent
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.compose.jetchat.FunctionalityNotAvailablePopup
import com.example.compose.jetchat.R
import com.example.compose.jetchat.components.JetchatAppBar
import com.example.compose.jetchat.data.exampleUiState
import com.example.compose.jetchat.theme.JetchatTheme
import kotlinx.coroutines.launch
/**
* Entry point for a conversation screen.
*
* @param uiState [ConversationUiState] that contains messages to display
* @param navigateToProfile User action when navigation to a profile is requested
* @param modifier [Modifier] to apply to this layout node
* @param onNavIconPressed Sends an event up when the user clicks on the menu
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ConversationContent(
uiState: ConversationUiState,
navigateToProfile: (String) -> Unit,
modifier: Modifier = Modifier,
onNavIconPressed: () -> Unit = { },
) {
val authorMe = stringResource(R.string.author_me)
val timeNow = stringResource(id = R.string.now)
val scrollState = rememberLazyListState()
val topBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
val scope = rememberCoroutineScope()
var background by remember {
mutableStateOf(Color.Transparent)
}
var borderStroke by remember {
mutableStateOf(Color.Transparent)
}
val dragAndDropCallback = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (clipData.itemCount < 1) {
return false
}
uiState.addMessage(
Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow),
)
return true
}
override fun onStarted(event: DragAndDropEvent) {
super.onStarted(event)
borderStroke = Color.Red
}
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
background = Color.Red.copy(alpha = .3f)
}
override fun onExited(event: DragAndDropEvent) {
super.onExited(event)
background = Color.Transparent
}
override fun onEnded(event: DragAndDropEvent) {
super.onEnded(event)
background = Color.Transparent
borderStroke = Color.Transparent
}
}
}
Scaffold(
topBar = {
ChannelNameBar(
channelName = uiState.channelName,
channelMembers = uiState.channelMembers,
onNavIconPressed = onNavIconPressed,
scrollBehavior = scrollBehavior,
)
},
// Exclude ime and navigation bar padding so this can be added by the UserInput composable
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.exclude(WindowInsets.navigationBars)
.exclude(WindowInsets.ime),
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Column(
Modifier.fillMaxSize().padding(paddingValues)
.background(color = background)
.border(width = 2.dp, color = borderStroke)
.dragAndDropTarget(shouldStartDragAndDrop = { event ->
event
.mimeTypes()
.contains(
ClipDescription.MIMETYPE_TEXT_PLAIN,
)
}, target = dragAndDropCallback),
) {
Messages(
messages = uiState.messages,
navigateToProfile = navigateToProfile,
modifier = Modifier.weight(1f),
scrollState = scrollState,
)
UserInput(
onMessageSent = { content ->
uiState.addMessage(
Message(authorMe, content, timeNow),
)
},
resetScroll = {
scope.launch {
scrollState.scrollToItem(0)
}
},
// let this element handle the padding so that the elevation is shown behind the
// navigation bar
modifier = Modifier.navigationBarsPadding().imePadding(),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelNameBar(
channelName: String,
channelMembers: Int,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
onNavIconPressed: () -> Unit = { },
) {
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
if (functionalityNotAvailablePopupShown) {
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
}
JetchatAppBar(
modifier = modifier,
scrollBehavior = scrollBehavior,
onNavIconPressed = onNavIconPressed,
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Channel name
Text(
text = channelName,
style = MaterialTheme.typography.titleMedium,
)
// Number of members
Text(
text = stringResource(R.string.members, channelMembers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = {
// Search icon
Icon(
imageVector = Icons.Outlined.Search,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
.padding(horizontal = 12.dp, vertical = 16.dp)
.height(24.dp),
contentDescription = stringResource(id = R.string.search),
)
// Info icon
Icon(
imageVector = Icons.Outlined.Info,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
.padding(horizontal = 12.dp, vertical = 16.dp)
.height(24.dp),
contentDescription = stringResource(id = R.string.info),
)
},
)
}
const val ConversationTestTag = "ConversationTestTag"
@Composable
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
Box(modifier = modifier) {
val authorMe = stringResource(id = R.string.author_me)
LazyColumn(
reverseLayout = true,
state = scrollState,
modifier = Modifier
.testTag(ConversationTestTag)
.fillMaxSize(),
) {
for (index in messages.indices) {
val prevAuthor = messages.getOrNull(index - 1)?.author
val nextAuthor = messages.getOrNull(index + 1)?.author
val content = messages[index]
val isFirstMessageByAuthor = prevAuthor != content.author
val isLastMessageByAuthor = nextAuthor != content.author
// Hardcode day dividers for simplicity
if (index == messages.size - 1) {
item {
DayHeader("20 Aug")
}
} else if (index == 2) {
item {
DayHeader("Today")
}
}
item {
Message(
onAuthorClick = { name -> navigateToProfile(name) },
msg = content,
isUserMe = content.author == authorMe,
isFirstMessageByAuthor = isFirstMessageByAuthor,
isLastMessageByAuthor = isLastMessageByAuthor,
)
}
}
}
// Jump to bottom button shows up when user scrolls past a threshold.
// Convert to pixels:
val jumpThreshold = with(LocalDensity.current) {
JumpToBottomThreshold.toPx()
}
// Show the button if the first visible item is not the first one or if the offset is
// greater than the threshold.
val jumpToBottomButtonEnabled by remember {
derivedStateOf {
scrollState.firstVisibleItemIndex != 0 ||
scrollState.firstVisibleItemScrollOffset > jumpThreshold
}
}
JumpToBottom(
// Only show if the scroller is not at the bottom
enabled = jumpToBottomButtonEnabled,
onClicked = {
scope.launch {
scrollState.animateScrollToItem(0)
}
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
fun Message(
onAuthorClick: (String) -> Unit,
msg: Message,
isUserMe: Boolean,
isFirstMessageByAuthor: Boolean,
isLastMessageByAuthor: Boolean,
) {
val borderColor = if (isUserMe) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.tertiary
}
val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
Row(modifier = spaceBetweenAuthors) {
if (isLastMessageByAuthor) {
// Avatar
Image(
modifier = Modifier
.clickable(onClick = { onAuthorClick(msg.author) })
.padding(horizontal = 16.dp)
.size(42.dp)
.border(1.5.dp, borderColor, CircleShape)
.border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
.clip(CircleShape)
.align(Alignment.Top),
painter = painterResource(id = msg.authorImage),
contentScale = ContentScale.Crop,
contentDescription = null,
)
} else {
// Space under avatar
Spacer(modifier = Modifier.width(74.dp))
}
AuthorAndTextMessage(
msg = msg,
isUserMe = isUserMe,
isFirstMessageByAuthor = isFirstMessageByAuthor,
isLastMessageByAuthor = isLastMessageByAuthor,
authorClicked = onAuthorClick,
modifier = Modifier
.padding(end = 16.dp)
.weight(1f),
)
}
}
@Composable
fun AuthorAndTextMessage(
msg: Message,
isUserMe: Boolean,
isFirstMessageByAuthor: Boolean,
isLastMessageByAuthor: Boolean,
authorClicked: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
if (isLastMessageByAuthor) {
AuthorNameTimestamp(msg)
}
ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
if (isFirstMessageByAuthor) {
// Last bubble before next author
Spacer(modifier = Modifier.height(8.dp))
} else {
// Between bubbles
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Composable
private fun AuthorNameTimestamp(msg: Message) {
// Combine author and timestamp for a11y.
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Text(
text = msg.author,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
.paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = msg.timestamp,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alignBy(LastBaseline),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
@Composable
fun DayHeader(dayString: String) {
Row(
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp)
.height(16.dp),
) {
DayHeaderLine()
Text(
text = dayString,
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DayHeaderLine()
}
}
@Composable
private fun RowScope.DayHeaderLine() {
Divider(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
)
}
@Composable
fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
val backgroundBubbleColor = if (isUserMe) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
Column {
Surface(
color = backgroundBubbleColor,
shape = ChatBubbleShape,
) {
ClickableMessage(
message = message,
isUserMe = isUserMe,
authorClicked = authorClicked,
)
}
message.image?.let {
Spacer(modifier = Modifier.height(4.dp))
Surface(
color = backgroundBubbleColor,
shape = ChatBubbleShape,
) {
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)
}
}
}
}
@Composable
fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
val uriHandler = LocalUriHandler.current
val styledMessage = messageFormatter(
text = message.content,
primary = isUserMe,
)
ClickableText(
text = styledMessage,
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
modifier = Modifier.padding(16.dp),
onClick = {
styledMessage
.getStringAnnotations(start = it, end = it)
.firstOrNull()
?.let { annotation ->
when (annotation.tag) {
SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
else -> Unit
}
}
},
)
}
@Preview
@Composable
fun ConversationPreview() {
JetchatTheme {
ConversationContent(
uiState = exampleUiState,
navigateToProfile = { },
)
}
}
@Preview
@Composable
fun ChannelBarPrev() {
JetchatTheme {
ChannelNameBar(channelName = "composers", channelMembers = 52)
}
}
@Preview
@Composable
fun DayHeaderPrev() {
DayHeader("Aug 6")
}
private val JumpToBottomThreshold = 56.dp
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.conversation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.compose.ui.platform.ComposeView
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import com.example.compose.jetchat.MainViewModel
import com.example.compose.jetchat.R
import com.example.compose.jetchat.data.exampleUiState
import com.example.compose.jetchat.theme.JetchatTheme
class ConversationFragment : Fragment() {
private val activityViewModel: MainViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
ComposeView(inflater.context).apply {
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
setContent {
JetchatTheme {
ConversationContent(
uiState = exampleUiState,
navigateToProfile = { user ->
// Click callback
val bundle = bundleOf("userId" to user)
findNavController().navigate(
R.id.nav_profile,
bundle,
)
},
onNavIconPressed = {
activityViewModel.openDrawer()
},
)
}
}
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.conversation
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.toMutableStateList
import com.example.compose.jetchat.R
class ConversationUiState(val channelName: String, val channelMembers: Int, initialMessages: List<Message>) {
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
val messages: List<Message> = _messages
fun addMessage(msg: Message) {
_messages.add(0, msg) // Add to the beginning of the list
}
}
@Immutable
data class Message(
val author: String,
val content: String,
val timestamp: String,
val image: Int? = null,
val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else,
)
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.conversation
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.compose.jetchat.R
private enum class Visibility {
VISIBLE,
GONE,
}
/**
* Shows a button that lets the user scroll to the bottom.
*/
@Composable
fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) {
// Show Jump to Bottom button
val transition = updateTransition(
if (enabled) Visibility.VISIBLE else Visibility.GONE,
label = "JumpToBottom visibility animation",
)
val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") {
if (it == Visibility.GONE) {
(-32).dp
} else {
32.dp
}
}
if (bottomOffset > 0.dp) {
ExtendedFloatingActionButton(
icon = {
Icon(
imageVector = Icons.Filled.ArrowDownward,
modifier = Modifier.height(18.dp),
contentDescription = null,
)
},
text = {
Text(text = stringResource(id = R.string.jumpBottom))
},
onClick = onClicked,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
modifier = modifier
.offset(x = 0.dp, y = -bottomOffset)
.height(36.dp),
)
}
}
@Preview
@Composable
fun JumpToBottomPreview() {
JumpToBottom(enabled = true, onClicked = {})
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt
================================================
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.compose.jetchat.conversation
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
// Regex containing the syntax tokens
val symbolPattern by lazy {
Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
}
// Accepted annotations for the ClickableTextWrapper
enum class SymbolAnnotationType {
PERSON,
LINK,
}
typealias StringAnnotation = AnnotatedString.Range<String>
// Pair returning styled content and annotation for ClickableText when matching syntax token
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>
/**
* Format a message following Markdown-lite syntax
* | @username -> bold, primary color and clickable element
* | http(s)://... -> clickable link, opening it into the browser
* | *bold* -> bold
* | _italic_ -> italic
* | ~strikethrough~ -> strikethrough
* | `MyClass.myMethod` -> inline code styling
*
* @param text contains message to be parsed
* @return AnnotatedString with annotations used inside the ClickableText wrapper
*/
@Composable
fun messageFormatter(text: String, primary: Boolean): AnnotatedString {
val tokens = symbolPattern.findAll(text)
return buildAnnotatedString {
var cursorPosition = 0
val codeSnippetBackground =
if (primary) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.surface
}
for (token in tokens) {
append(text.slice(cursorPosition until token.range.first))
val (annotatedString, stringAnnotation) = getSymbolAnnotation(
matchResult = token,
colorScheme = MaterialTheme.colorScheme,
primary = primary,
codeSnippetBackground = codeSnippetBackground,
)
append(annotatedString)
if (stringAnnotation != null) {
val (item, start, end, tag) = stringAnnotation
addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
}
cursorPosition = token.range.last + 1
}
if (!tokens.none()) {
append(text.slice(cursorPosition..text.lastIndex))
} else {
append(text)
}
}
}
/**
* Map regex matches found in a message with supported syntax symbols
*
* @param matchResult is a regex result matching our syntax symbols
* @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
*/
private fun getSymbolAnnotation(
matchResult: MatchResult,
colorScheme: ColorScheme,
primary: Boolean,
codeSnippetBackground: Color,
): SymbolAnnotation {
return when (matchResult.value.first()) {
'@' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value,
spanStyle = SpanStyle(
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
fontWeight = FontWeight.Bold,
),
),
StringAnnotation(
item = matchResult.value.substring(1),
start = matchResult.range.first,
end = matchResult.range.last,
tag = SymbolAnnotationType.PERSON.name,
),
)
'*' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value.trim('*'),
spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
),
null,
)
'_' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value.trim('_'),
spanStyle = SpanStyle(fontStyle = FontStyle.Italic),
),
null,
)
'~' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value.trim('~'),
spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
),
null,
)
'`' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value.trim('`'),
spanStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
background = codeSnippetBackground,
baselineShift = BaselineShift(0.2f),
),
),
null,
)
'h' -> SymbolAnnotation(
AnnotatedString(
text = matchResult.value,
spanStyle = SpanStyle(
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
),
),
StringAnnotation(
item = matchResult.value,
start = matchResult.range.first,
end = matchResult.range.last,
tag = SymbolAnnotationType.LINK.name,
),
)
else -> SymbolAnnotation(AnnotatedString(matchResult.value), null)
}
}
================================================
FILE: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt
================================================
/*
* Copyright 2023 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.compose.jetchat.conversation
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment