Created
June 6, 2025 08:25
-
-
Save mikescamell/2d85ebffbd864e88aad5cd09717fc355 to your computer and use it in GitHub Desktop.
This file has been truncated, but you can view the full file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 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: | |
 | |
### 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 • %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 • %2$d mins</string> | |
<string name="elapsed_time">%1$s • %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 • %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